diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fdbb53e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.env +.env.local +__pycache__/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.venv/ +*.pyc +node_modules/ +dist/ +coverage/ +tests/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d597922 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +DATABASE_URL="postgresql://miniapp:miniapp_dev_password@localhost:5433/miniapp" +JWT_SECRET="replace-with-a-long-random-secret-before-production" +PORT=4000 +LOG_LEVEL="info" +CORS_ORIGINS="*" diff --git a/.gitignore b/.gitignore index 9154f4c..fcc0f3a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,26 +1,13 @@ -# ---> Java -# Compiled class file -*.class - -# Log file +node_modules/ +dist/ +.env +.env.local *.log - -# BlueJ files -*.ctxt - -# Mobile Tools for Java (J2ME) -.mtj.tmp/ - -# Package Files # -*.jar -*.war -*.nar -*.ear -*.zip -*.tar.gz -*.rar - -# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml -hs_err_pid* -replay_pid* - +coverage/ +.prisma/ +__pycache__/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.venv/ +*.pyc diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9063b41 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,98 @@ +# AGENTS.md + +## 全局协作规则 + +- 默认全程使用中文回答;除非用户明确要求,不切换语言。 +- 回答保持简洁直接,避免无效铺垫和空话。 +- 需求不清晰时先提问确认;不要自行猜测并执行有风险操作。 +- 默认只读优先。创建、修改、删除文件必须有用户明确授权。 +- 严格按当前需求工作,不额外加功能、不扩大改动范围。 +- 识别到密钥、Token、真实环境变量、账号密码、隐私配置时,禁止展示、复述或输出。 +- 不读取、输出或提交 `.env`、`.env.local` 等真实环境文件。 + +## 项目定位 + +`WonderQ-Admin` 是独立的 WonderQ 后端 API 服务,当前技术栈为 Python + FastAPI + SQLAlchemy 2 + Alembic + PostgreSQL + JWT + Pydantic。项目为 H5 前台提供 Public API,为后台管理端提供 Admin API,并通过 Docker Compose 部署 API、PostgreSQL 和 Redis。 + +## 当前目录结构 + +```text +WonderQ-Admin/ +├─ app/ +│ ├─ main.py # FastAPI 应用入口、CORS、错误处理、路由注册 +│ ├─ config.py # 环境变量配置 +│ ├─ database.py # SQLAlchemy engine/session/Base +│ ├─ models.py # ORM 模型,兼容原 Prisma 表结构 +│ ├─ schemas.py # Pydantic 请求校验 +│ ├─ auth.py # JWT 与后台鉴权 +│ ├─ serializers.py # SQLAlchemy 对象响应序列化 +│ ├─ content.py # Python seed 内容源 +│ ├─ seed.py # 初始化/重置数据命令 +│ └─ routers/ +│ ├─ public.py # H5 Public API +│ ├─ admin.py # 后台 Admin API +│ └─ shared.py # 路由共享查询 +├─ alembic/ # 数据库迁移 baseline +├─ data/generated-products.json +├─ tests/ # 单元测试和接口冒烟测试 +├─ Dockerfile # API 镜像 +├─ docker-compose.yml # api/postgres/redis 编排 +├─ requirements.txt # Python 依赖 +├─ pyproject.toml # Python 项目元数据与 pytest 配置 +├─ .env.example # 环境变量模板 +└─ README.md # 启动、部署、迁移说明 +``` + +## 启动方式 + +本地开发: + +```bash +python -m venv .venv +.venv\Scripts\activate +pip install -r requirements.txt +docker compose up -d postgres redis +alembic upgrade head +python -m app.seed +uvicorn app.main:app --host 0.0.0.0 --port 4000 --reload +``` + +Docker 全量启动: + +```bash +docker compose up --build +``` + +健康检查:`http://localhost:4000/health`。 + +## 测试流程 + +- 修改 Python 代码后运行 `pytest`。 +- 修改数据库模型或迁移后运行 `alembic upgrade head`,并在空库验证 `python -m app.seed`。 +- 保留已有 PostgreSQL 数据时,先备份,再使用 `alembic stamp head` 标记 baseline。 +- Docker 相关变更后运行 `docker compose up --build` 并检查 `/health`。 + +## 开发准则 + +- 优先保持现有 API 路径和响应结构兼容,不主动重设计接口。 +- 外部输入必须通过 Pydantic schema 校验。 +- 数据库访问统一通过 `app/database.py` 提供的 Session。 +- 表名和字段名需要兼容原 Prisma 生成的 mixed-case PostgreSQL 结构。 +- Admin API 默认需要 `require_admin`,登录接口除外。 +- 后台数据变更继续记录 `AuditLog`。 +- 真实密钥只从环境变量读取,禁止写入源码、测试或文档。 +- `python -m app.seed` 会重置内容数据,生产环境使用前必须明确确认。 + +## 锁定核心文件 + +未经用户明确授权禁止修改: + +- `.env`、`.env.local`、生产环境变量和任何密钥配置。 +- `app/models.py`、`alembic/versions/*`:数据库结构和迁移。 +- `app/auth.py`:后台鉴权逻辑。 +- `app/routers/admin.py`、`app/routers/public.py`:核心 API 行为。 +- `app/seed.py`、`app/content.py`、`data/generated-products.json`:初始化内容和迁移数据源。 +- `Dockerfile`、`docker-compose.yml`:部署入口。 +- `requirements.txt`、`pyproject.toml`:依赖和测试配置。 + +如确需修改上述文件,先说明原因、影响范围、验证方式,并等待用户确认。 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..41ff1e9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.12-slim AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY alembic.ini . +COPY alembic ./alembic +COPY app ./app +COPY data ./data + +EXPOSE 4000 + +CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-4000}"] diff --git a/README.md b/README.md index e5462f2..6ba4f30 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,125 @@ # WonderQ-Admin +独立的 WonderQ 后端 API 服务,基于 Python、FastAPI、SQLAlchemy 2、Alembic、PostgreSQL、JWT 和 Pydantic。 + +## 本地启动 + +1. 复制环境变量: + +```bash +cp .env.example .env +``` + +2. 创建虚拟环境并安装依赖: + +```bash +python -m venv .venv +.venv\Scripts\activate +pip install -r requirements.txt +``` + +3. 启动 PostgreSQL 和 Redis: + +```bash +docker compose up -d postgres redis +``` + +4. 初始化数据库结构并导入初始内容: + +```bash +alembic upgrade head +python -m app.seed +``` + +5. 启动 API: + +```bash +uvicorn app.main:app --host 0.0.0.0 --port 4000 --reload +``` + +访问: + +- 健康检查:http://localhost:4000/health +- 默认后台账号:admin@example.com / ChangeMe123! + +## Docker 部署 + +完整本地部署: + +```bash +docker compose up --build +``` + +服务包含: + +- `api`:FastAPI 服务,默认监听 `4000` +- `postgres`:PostgreSQL 16,宿主机端口 `5433` +- `redis`:Redis 7,宿主机端口 `6380` + +已有生产数据库迁移到 Python 版时,先备份数据库,再执行: + +```bash +alembic stamp head +``` + +空库或全新环境使用: + +```bash +alembic upgrade head +python -m app.seed +``` + +## 目录说明 + +- `app/`:FastAPI 应用、路由、鉴权、数据库模型、schema、seed 逻辑。 +- `app/routers/public.py`:H5 Public API。 +- `app/routers/admin.py`:后台 Admin API。 +- `app/models.py`:SQLAlchemy ORM,兼容原 Prisma 表结构。 +- `alembic/`:数据库迁移 baseline。 +- `data/generated-products.json`:从 H5 拆出的产品初始数据。 +- `docker-compose.yml`:API、PostgreSQL 和 Redis 编排。 +- `tests/`:基础单元和接口冒烟测试。 + +## 常用命令 + +| 命令 | 说明 | +| --- | --- | +| `uvicorn app.main:app --reload --port 4000` | 启动开发服务 | +| `alembic upgrade head` | 创建或升级数据库结构 | +| `alembic stamp head` | 标记已有数据库已处于当前 baseline | +| `python -m app.seed` | 重置并导入初始化内容 | +| `python -m app.seed --no-reset` | 只确保默认后台账号存在 | +| `pytest` | 运行测试 | +| `docker compose up --build` | 构建并启动完整服务 | + +## API 兼容范围 + +Python 版保留原有核心路径: + +- `GET /health` +- `GET /api/public/site-config` +- `GET /api/public/products` +- `GET /api/public/products/{id}` +- `GET /api/public/destinations` +- `POST /api/public/leads` +- `POST /api/admin/auth/login` +- `GET /api/admin/me` +- `GET /api/admin/dashboard` +- `GET /api/admin/products` +- `POST /api/admin/products` +- `PATCH /api/admin/products/{id}` +- `GET /api/admin/destinations` +- `GET /api/admin/site-config` +- `PATCH /api/admin/site-config/{module}/{id}` +- `GET /api/admin/leads` +- `PATCH /api/admin/leads/{id}/status` +- `GET /api/admin/media-assets` +- `POST /api/admin/reset-guizhou-content` +- `POST /api/admin/publish` + +## 注意事项 + +- 生产环境必须替换 `JWT_SECRET`,禁止使用示例值。 +- 真实环境变量只放在 `.env` 或部署平台密钥中,不提交到 Git。 +- `python -m app.seed` 会重置站点内容、产品、目的地、活动和媒体数据;不要直接对生产库执行。 +- 保留现有 PostgreSQL 数据时使用 `alembic stamp head`,不要在已有生产表上直接运行初始建表迁移。 diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..42e12cb --- /dev/null +++ b/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = postgresql+psycopg://miniapp:miniapp_dev_password@localhost:5433/miniapp + +[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/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..05aff71 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,41 @@ +from logging.config import fileConfig +from alembic import context +from app.config import get_settings +from app.database import Base +from app import models # noqa: F401 + + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + context.configure( + url=get_settings().sqlalchemy_url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + from sqlalchemy import engine_from_config, pool + + configuration = config.get_section(config.config_ini_section, {}) + configuration["sqlalchemy.url"] = get_settings().sqlalchemy_url + connectable = engine_from_config(configuration, 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/alembic/versions/.gitkeep b/alembic/versions/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/alembic/versions/.gitkeep @@ -0,0 +1 @@ + diff --git a/alembic/versions/0001_initial_schema.py b/alembic/versions/0001_initial_schema.py new file mode 100644 index 0000000..ea69dc4 --- /dev/null +++ b/alembic/versions/0001_initial_schema.py @@ -0,0 +1,32 @@ +"""Initial SQLAlchemy schema. + +Revision ID: 0001_initial_schema +Revises: +Create Date: 2026-06-30 +""" + +from alembic import context, op +from sqlalchemy import inspect +from app.database import Base +from app import models # noqa: F401 + + +revision = "0001_initial_schema" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + if context.is_offline_mode(): + Base.metadata.create_all(bind=bind, checkfirst=False) + return + existing_tables = set(inspect(bind).get_table_names()) + for table in Base.metadata.sorted_tables: + if table.name not in existing_tables: + table.create(bind) + + +def downgrade() -> None: + Base.metadata.drop_all(bind=op.get_bind(), checkfirst=not context.is_offline_mode()) diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..7e05a00 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +"""WonderQ Admin Python API.""" diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..c214fab --- /dev/null +++ b/app/auth.py @@ -0,0 +1,56 @@ +from datetime import datetime, timedelta, timezone +import bcrypt +import jwt +from fastapi import Depends, HTTPException, Request, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.orm import Session +from .config import get_settings +from .database import get_db +from .models import AdminUser + + +bearer = HTTPBearer(auto_error=False) + + +def verify_password(password: str, password_hash: str) -> bool: + return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8")) + + +def hash_password(password: str, rounds: int = 12) -> str: + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt(rounds)).decode("utf-8") + + +def create_token(user: AdminUser) -> str: + settings = get_settings() + now = datetime.now(timezone.utc) + payload = { + "sub": user.id, + "email": user.email, + "role": user.role, + "iat": now, + "exp": now + timedelta(hours=settings.jwt_expires_hours), + } + return jwt.encode(payload, settings.jwt_secret, algorithm="HS256") + + +def require_admin( + request: Request, + credentials: HTTPAuthorizationCredentials | None = Depends(bearer), + db: Session = Depends(get_db), +) -> AdminUser: + if credentials is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请先登录后台") + try: + payload = jwt.decode(credentials.credentials, get_settings().jwt_secret, algorithms=["HS256"]) + except jwt.PyJWTError as exc: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请先登录后台") from exc + user_id = payload.get("sub") + user = db.get(AdminUser, user_id) if user_id else None + if not user or not user.isActive: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请先登录后台") + request.state.actor_id = user.id + return user + + +def get_actor_id(request: Request) -> str | None: + return getattr(request.state, "actor_id", None) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..e29bbaa --- /dev/null +++ b/app/config.py @@ -0,0 +1,29 @@ +from functools import lru_cache +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + database_url: str = Field(default="postgresql://miniapp:miniapp_dev_password@localhost:5433/miniapp") + jwt_secret: str = Field(default="dev-only-change-me-before-production") + jwt_expires_hours: int = Field(default=8) + log_level: str = Field(default="info") + port: int = Field(default=4000) + cors_origins: str = Field(default="*") + + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + @property + def sqlalchemy_url(self) -> str: + parsed = urlsplit(self.database_url) + query = urlencode([(key, value) for key, value in parse_qsl(parsed.query, keep_blank_values=True) if key != "schema"]) + normalized = urlunsplit((parsed.scheme, parsed.netloc, parsed.path, query, parsed.fragment)) + if normalized.startswith("postgresql://"): + return normalized.replace("postgresql://", "postgresql+psycopg://", 1) + return normalized + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/app/content.py b/app/content.py new file mode 100644 index 0000000..4a72bc6 --- /dev/null +++ b/app/content.py @@ -0,0 +1,83 @@ +HERO_SLIDES = [ + { + "title": "经典人文打卡线路", + "kicker": "经典人文", + "action": "查看线路", + "image": "/assets/guizhou/huangguoshu-waterfall.jpg", + "targetValue": "classic-deal", + }, + { + "title": "极限山野户外野咖线路", + "kicker": "山野户外", + "action": "探索玩法", + "image": "/assets/guizhou/wanfenglin.jpg", + "targetValue": "outdoor-deal", + }, + { + "title": "人文+户外综合混搭线路", + "kicker": "人文+户外", + "action": "定制小团", + "image": "/assets/guizhou/xijiang-miao-village.jpg", + "targetValue": "classic-deal", + }, +] + +DESTINATIONS = [ + ("贵阳", "/assets/guizhou/jiaxiu-tower.jpg"), + ("黄果树", "/assets/guizhou/huangguoshu-waterfall.jpg"), + ("荔波小七孔", "/assets/guizhou/libo-xiaoqikong.jpg"), + ("西江苗寨", "/assets/guizhou/xijiang-miao-village.jpg"), + ("梵净山", "/assets/guizhou/fanjing-mountain.jpg"), + ("镇远古城", "/assets/guizhou/zhenyuan-ancient-town.jpg"), + ("万峰林", "/assets/guizhou/wanfenglin.jpg"), + ("织金洞", "/assets/guizhou/zhijin-cave.jpg"), + ("百里杜鹃", "/assets/guizhou/baili-azalea.jpg"), + ("乌蒙草原", "/assets/guizhou/wumeng-grassland.jpg"), + ("赤水丹霞", "/assets/guizhou/chishui-danxia.jpg"), + ("肇兴侗寨", "/assets/guizhou/dong-village.jpg"), + ("加榜梯田", "/assets/guizhou/jiabang-terrace.jpg"), + ("青岩古镇", "/assets/guizhou/zhenyuan-ancient-town.jpg"), + ("娄山关", "/assets/guizhou/chishui-danxia.jpg"), + ("十二背后", "/assets/guizhou/zhijin-cave.jpg"), + ("黔东南", "/assets/guizhou/xijiang-miao-village.jpg"), +] + +ALIASES = { + "贵阳": ["贵阳", "青岩", "花溪", "高坡", "天河潭"], + "黄果树": ["黄果树", "安顺", "坝陵河", "瀑布"], + "荔波小七孔": ["荔波", "小七孔", "茂兰", "水上森林"], + "西江苗寨": ["西江", "苗寨", "郎德", "雷山", "苗岭"], + "梵净山": ["梵净山", "铜仁", "云舍", "寨沙"], + "镇远古城": ["镇远", "青龙洞", "古城"], + "肇兴侗寨": ["肇兴", "侗寨", "堂安", "加榜", "黎平", "侗族大歌"], + "万峰林": ["万峰林", "万峰湖", "马岭河", "兴义", "黔西南"], + "织金洞": ["织金洞", "织金", "洞穴", "喀斯特"], + "赤水丹霞": ["赤水", "丹霞", "竹海", "丙安"], + "青岩古镇": ["青岩", "古镇", "屯堡"], + "百里杜鹃": ["百里杜鹃", "毕节", "花季"], + "乌蒙草原": ["乌蒙", "六盘水", "草原", "避暑"], + "加榜梯田": ["加榜", "梯田", "黔东南"], + "娄山关": ["娄山关", "遵义", "红色文化", "黔北"], + "十二背后": ["十二背后", "石龙洞", "酷玩森林", "罗秧河", "莲花古洞", "溶洞"], + "黔东南": ["黔东南", "苗绣", "蜡染", "侗族大歌", "长桌宴"], +} + +THEMES = [ + ("独特的风光地貌", "/assets/guizhou/fanjing-mountain.jpg"), + ("天然的溶洞探险", "/assets/guizhou/zhijin-cave.jpg"), + ("特有的人文体验", "/assets/guizhou/miao-costume.jpg"), + ("山野里的咖啡厅", "/assets/guizhou/wanfenglin.jpg"), +] + +CTAS = [ + ("万趣贵州小包团权益", "/assets/guizhou/huangguoshu-waterfall.jpg", "cardBenefits", None), + ("咨询贵州定制游服务管家", "/assets/guizhou/xijiang-miao-village.jpg", "campaign", "classic-deal"), + ("查看贵州省内目的地", "/assets/guizhou/libo-xiaoqikong.jpg", "destinationPicker", None), + ("提交贵州出行需求", "/assets/guizhou/shuichunhe-rafting.jpg", "demand", None), +] + +CAMPAIGNS = [ + {"slug": "classic-deal", "title": "经典打卡特惠", "start": 0, "end": 8, "coverImage": HERO_SLIDES[0]["image"]}, + {"slug": "outdoor-deal", "title": "山野野咖特惠", "start": 8, "end": 16, "coverImage": HERO_SLIDES[1]["image"]}, + {"slug": "mixed-route", "title": "人文户外混搭", "start": 16, "end": 24, "coverImage": HERO_SLIDES[2]["image"]}, +] diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..4543306 --- /dev/null +++ b/app/database.py @@ -0,0 +1,21 @@ +from collections.abc import Generator +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker +from .config import get_settings + + +class Base(DeclarativeBase): + pass + + +settings = get_settings() +engine = create_engine(settings.sqlalchemy_url, pool_pre_ping=True) +SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False, expire_on_commit=False) + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..8e23268 --- /dev/null +++ b/app/main.py @@ -0,0 +1,43 @@ +import logging +from fastapi import FastAPI, HTTPException, Request +from fastapi.exceptions import RequestValidationError +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from .config import get_settings +from .routers import admin, public + + +def create_app() -> FastAPI: + settings = get_settings() + logging.basicConfig(level=settings.log_level.upper()) + + app = FastAPI(title="WonderQ Admin API") + origins = ["*"] if settings.cors_origins == "*" else [item.strip() for item in settings.cors_origins.split(",") if item.strip()] + app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + @app.exception_handler(RequestValidationError) + async def validation_exception_handler(_request: Request, exc: RequestValidationError): + first = exc.errors()[0] if exc.errors() else {} + return JSONResponse(status_code=400, content={"message": first.get("msg", "请求参数不正确")}) + + @app.exception_handler(HTTPException) + async def http_exception_handler(_request: Request, exc: HTTPException): + return JSONResponse(status_code=exc.status_code, content={"message": exc.detail}) + + @app.exception_handler(Exception) + async def unhandled_exception_handler(request: Request, exc: Exception): + request.app.logger.exception(exc) if hasattr(request.app, "logger") else logging.exception(exc) + return JSONResponse(status_code=500, content={"message": "服务暂时不可用"}) + + app.include_router(public.router) + app.include_router(admin.router) + return app + + +app = create_app() diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..ca43e48 --- /dev/null +++ b/app/models.py @@ -0,0 +1,275 @@ +from datetime import datetime, timezone +from uuid import uuid4 +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy.dialects.postgresql import ARRAY, JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship +from .database import Base + + +def utc_now() -> datetime: + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def new_id() -> str: + return str(uuid4()) + + +class AdminUser(Base): + __tablename__ = "AdminUser" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) + email: Mapped[str] = mapped_column(String, unique=True, nullable=False) + name: Mapped[str] = mapped_column(String, nullable=False) + passwordHash: Mapped[str] = mapped_column(String, nullable=False) + role: Mapped[str] = mapped_column(String, default="admin", nullable=False) + isActive: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False) + updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) + + auditLogs: Mapped[list["AuditLog"]] = relationship(back_populates="actor") + assignedLeads: Mapped[list["Lead"]] = relationship(back_populates="assignedUser", foreign_keys="Lead.assignedUserId") + + +class MediaAsset(Base): + __tablename__ = "MediaAsset" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) + url: Mapped[str] = mapped_column(String, unique=True, nullable=False) + name: Mapped[str | None] = mapped_column(String) + mimeType: Mapped[str | None] = mapped_column(String) + sizeBytes: Mapped[int | None] = mapped_column(Integer) + group: Mapped[str | None] = mapped_column(String) + createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False) + updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) + + +class HeroSlide(Base): + __tablename__ = "HeroSlide" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) + title: Mapped[str] = mapped_column(String, nullable=False) + kicker: Mapped[str | None] = mapped_column(String) + actionLabel: Mapped[str | None] = mapped_column(String) + image: Mapped[str] = mapped_column(String, nullable=False) + targetType: Mapped[str | None] = mapped_column(String) + targetValue: Mapped[str | None] = mapped_column(String) + sortOrder: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + isActive: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False) + updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) + + +class Destination(Base): + __tablename__ = "Destination" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) + name: Mapped[str] = mapped_column(String, unique=True, nullable=False) + slug: Mapped[str] = mapped_column(String, unique=True, nullable=False) + region: Mapped[str | None] = mapped_column(String) + image: Mapped[str | None] = mapped_column(String) + isHot: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + sortOrder: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + isActive: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False) + updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) + + aliases: Mapped[list["DestinationAlias"]] = relationship(back_populates="destination", cascade="all, delete-orphan") + products: Mapped[list["Product"]] = relationship(back_populates="destination") + + +class DestinationAlias(Base): + __tablename__ = "DestinationAlias" + __table_args__ = (UniqueConstraint("alias", "destinationId"),) + + id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) + alias: Mapped[str] = mapped_column(String, nullable=False) + destinationId: Mapped[str] = mapped_column(String, ForeignKey("Destination.id", ondelete="CASCADE"), nullable=False) + + destination: Mapped[Destination] = relationship(back_populates="aliases") + + +class ThemeCard(Base): + __tablename__ = "ThemeCard" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) + label: Mapped[str] = mapped_column(String, nullable=False) + image: Mapped[str] = mapped_column(String, nullable=False) + targetType: Mapped[str | None] = mapped_column(String) + targetValue: Mapped[str | None] = mapped_column(String) + sortOrder: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + isActive: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False) + updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) + + +class CtaBanner(Base): + __tablename__ = "CtaBanner" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) + alt: Mapped[str] = mapped_column(String, nullable=False) + image: Mapped[str] = mapped_column(String, nullable=False) + targetType: Mapped[str] = mapped_column(String, nullable=False) + targetValue: Mapped[str | None] = mapped_column(String) + sortOrder: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + isActive: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False) + updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) + + +class Product(Base): + __tablename__ = "Product" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) + sourceId: Mapped[int | None] = mapped_column(Integer, unique=True) + title: Mapped[str] = mapped_column(String, nullable=False) + subtitle: Mapped[str | None] = mapped_column(String) + destinationId: Mapped[str | None] = mapped_column(String, ForeignKey("Destination.id", ondelete="SET NULL")) + priceAmount: Mapped[int | None] = mapped_column(Integer) + priceUnit: Mapped[str] = mapped_column(String, default="起/人", nullable=False) + tags: Mapped[list[str]] = mapped_column(ARRAY(String), default=list, nullable=False) + coverImage: Mapped[str | None] = mapped_column(String) + summary: Mapped[str | None] = mapped_column(Text) + detailSections: Mapped[dict | list | None] = mapped_column(JSONB) + status: Mapped[str] = mapped_column(String, default="published", nullable=False) + sortWeight: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + publishedAt: Mapped[datetime | None] = mapped_column(DateTime) + createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False) + updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) + + destination: Mapped[Destination | None] = relationship(back_populates="products") + images: Mapped[list["ProductImage"]] = relationship(back_populates="product", cascade="all, delete-orphan") + campaignLinks: Mapped[list["CampaignProduct"]] = relationship(back_populates="product", cascade="all, delete-orphan") + leads: Mapped[list["Lead"]] = relationship(back_populates="sourceProduct") + orders: Mapped[list["Order"]] = relationship(back_populates="product") + + +class ProductImage(Base): + __tablename__ = "ProductImage" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) + productId: Mapped[str] = mapped_column(String, ForeignKey("Product.id", ondelete="CASCADE"), nullable=False) + url: Mapped[str] = mapped_column(String, nullable=False) + alt: Mapped[str | None] = mapped_column(String) + sortOrder: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + product: Mapped[Product] = relationship(back_populates="images") + + +class Campaign(Base): + __tablename__ = "Campaign" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) + slug: Mapped[str] = mapped_column(String, unique=True, nullable=False) + title: Mapped[str] = mapped_column(String, nullable=False) + description: Mapped[str | None] = mapped_column(Text) + coverImage: Mapped[str | None] = mapped_column(String) + status: Mapped[str] = mapped_column(String, default="published", nullable=False) + startsAt: Mapped[datetime | None] = mapped_column(DateTime) + endsAt: Mapped[datetime | None] = mapped_column(DateTime) + createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False) + updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) + + products: Mapped[list["CampaignProduct"]] = relationship(back_populates="campaign", cascade="all, delete-orphan") + + +class CampaignProduct(Base): + __tablename__ = "CampaignProduct" + + campaignId: Mapped[str] = mapped_column(String, ForeignKey("Campaign.id", ondelete="CASCADE"), primary_key=True) + productId: Mapped[str] = mapped_column(String, ForeignKey("Product.id", ondelete="CASCADE"), primary_key=True) + sortOrder: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + campaign: Mapped[Campaign] = relationship(back_populates="products") + product: Mapped[Product] = relationship(back_populates="campaignLinks") + + +class Lead(Base): + __tablename__ = "Lead" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) + destination: Mapped[str | None] = mapped_column(String) + phone: Mapped[str] = mapped_column(String, nullable=False) + travelDate: Mapped[datetime | None] = mapped_column(DateTime) + peopleCount: Mapped[int | None] = mapped_column(Integer) + budgetMin: Mapped[int | None] = mapped_column(Integer) + budgetMax: Mapped[int | None] = mapped_column(Integer) + note: Mapped[str | None] = mapped_column(Text) + sourcePage: Mapped[str | None] = mapped_column(String) + sourceProductId: Mapped[str | None] = mapped_column(String, ForeignKey("Product.id", ondelete="SET NULL")) + status: Mapped[str] = mapped_column(String, default="new", nullable=False) + assignedUserId: Mapped[str | None] = mapped_column(String, ForeignKey("AdminUser.id", ondelete="SET NULL")) + createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False) + updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) + + sourceProduct: Mapped[Product | None] = relationship(back_populates="leads") + assignedUser: Mapped[AdminUser | None] = relationship(back_populates="assignedLeads", foreign_keys=[assignedUserId]) + followups: Mapped[list["LeadFollowup"]] = relationship(back_populates="lead", cascade="all, delete-orphan") + + +class LeadFollowup(Base): + __tablename__ = "LeadFollowup" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) + leadId: Mapped[str] = mapped_column(String, ForeignKey("Lead.id", ondelete="CASCADE"), nullable=False) + content: Mapped[str] = mapped_column(Text, nullable=False) + nextAt: Mapped[datetime | None] = mapped_column(DateTime) + createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False) + + lead: Mapped[Lead] = relationship(back_populates="followups") + + +class Customer(Base): + __tablename__ = "Customer" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) + phone: Mapped[str] = mapped_column(String, unique=True, nullable=False) + name: Mapped[str | None] = mapped_column(String) + wechat: Mapped[str | None] = mapped_column(String) + note: Mapped[str | None] = mapped_column(Text) + createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False) + updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) + + orders: Mapped[list["Order"]] = relationship(back_populates="customer") + + +class Order(Base): + __tablename__ = "Order" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) + customerId: Mapped[str | None] = mapped_column(String, ForeignKey("Customer.id", ondelete="SET NULL")) + productId: Mapped[str | None] = mapped_column(String, ForeignKey("Product.id", ondelete="SET NULL")) + status: Mapped[str] = mapped_column(String, default="draft", nullable=False) + travelDate: Mapped[datetime | None] = mapped_column(DateTime) + amount: Mapped[int | None] = mapped_column(Integer) + note: Mapped[str | None] = mapped_column(Text) + createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False) + updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) + + customer: Mapped[Customer | None] = relationship(back_populates="orders") + product: Mapped[Product | None] = relationship(back_populates="orders") + + +class SiteVersion(Base): + __tablename__ = "SiteVersion" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) + title: Mapped[str] = mapped_column(String, nullable=False) + status: Mapped[str] = mapped_column(String, default="draft", nullable=False) + snapshot: Mapped[dict | list] = mapped_column(JSONB, nullable=False) + publishedAt: Mapped[datetime | None] = mapped_column(DateTime) + createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False) + + +class AuditLog(Base): + __tablename__ = "AuditLog" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) + actorId: Mapped[str | None] = mapped_column(String, ForeignKey("AdminUser.id", ondelete="SET NULL")) + action: Mapped[str] = mapped_column(String, nullable=False) + entity: Mapped[str] = mapped_column(String, nullable=False) + entityId: Mapped[str | None] = mapped_column(String) + before: Mapped[dict | list | None] = mapped_column(JSONB) + after: Mapped[dict | list | None] = mapped_column(JSONB) + createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False) + + actor: Mapped[AdminUser | None] = relationship(back_populates="auditLogs") diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..f7ec5ce --- /dev/null +++ b/app/routers/__init__.py @@ -0,0 +1 @@ +"""API routers.""" diff --git a/app/routers/admin.py b/app/routers/admin.py new file mode 100644 index 0000000..1659903 --- /dev/null +++ b/app/routers/admin.py @@ -0,0 +1,305 @@ +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from sqlalchemy import delete, func, or_, select +from sqlalchemy.orm import Session, selectinload +from ..auth import create_token, get_actor_id, require_admin, verify_password +from ..database import get_db +from ..models import ( + AdminUser, + AuditLog, + Campaign, + CtaBanner, + Destination, + HeroSlide, + Lead, + MediaAsset, + Product, + ProductImage, + SiteVersion, + ThemeCard, + utc_now, +) +from ..schemas import AdminProductQuery, LeadQuery, LeadStatusIn, LoginIn, ProductCreateIn, ProductUpdateIn, SiteConfigPatchIn +from ..seed import create_media, reset_guizhou_content +from ..serializers import destination_dict, encode_value, lead_dict, model_dict, product_dict +from .shared import site_config + + +router = APIRouter(prefix="/api/admin") + + +def normalize_images(images): + return [ + {"url": image.url.strip(), "alt": image.alt.strip() if image.alt else None, "sortOrder": image.sortOrder if image.sortOrder is not None else index} + for index, image in enumerate(images or []) + if image.url.strip() + ] + + +def normalize_detail_sections(sections): + normalized = [] + for section in sections or []: + blocks = [] + for block in section.blocks: + if block.type == "image": + url = block.url.strip() + if url: + blocks.append({"type": "image", "url": url, "alt": block.alt.strip() if block.alt else None}) + else: + text = block.text.strip() + if text: + blocks.append({"type": "text", "text": text}) + key = section.key.strip() + label = section.label.strip() + if key and label and blocks: + normalized.append({"key": key, "label": label, "title": section.title.strip() if section.title else None, "blocks": blocks}) + return normalized + + +def audit(db: Session, actor_id: str | None, action: str, entity: str, entity_id: str | None = None, after=None, before=None) -> None: + db.add( + AuditLog( + actorId=actor_id, + action=action, + entity=entity, + entityId=entity_id, + before=encode_value(before) if before is not None else None, + after=encode_value(after) if after is not None else None, + ) + ) + + +def load_product(db: Session, product_id: str) -> Product: + return db.scalars( + select(Product) + .options(selectinload(Product.destination), selectinload(Product.images)) + .where(Product.id == product_id) + ).one() + + +@router.post("/auth/login") +def login(body: LoginIn, db: Session = Depends(get_db)): + user = db.scalar(select(AdminUser).where(AdminUser.email == body.email)) + if not user or not user.isActive or not verify_password(body.password, user.passwordHash): + raise HTTPException(status_code=401, detail="账号或密码错误") + return { + "token": create_token(user), + "user": {"id": user.id, "email": user.email, "name": user.name, "role": user.role}, + } + + +@router.get("/me") +def me(user: AdminUser = Depends(require_admin)): + return {"id": user.id, "email": user.email, "name": user.name, "role": user.role} + + +@router.get("/dashboard") +def dashboard(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)): + stats = { + "productCount": db.scalar(select(func.count()).select_from(Product)), + "publishedProductCount": db.scalar(select(func.count()).select_from(Product).where(Product.status == "published")), + "destinationCount": db.scalar(select(func.count()).select_from(Destination).where(Destination.isActive.is_(True))), + "newLeadCount": db.scalar(select(func.count()).select_from(Lead).where(Lead.status == "new")), + "leadCount": db.scalar(select(func.count()).select_from(Lead)), + "campaignCount": db.scalar(select(func.count()).select_from(Campaign)), + } + recent = db.scalars( + select(Lead).options(selectinload(Lead.sourceProduct), selectinload(Lead.assignedUser)).order_by(Lead.createdAt.desc()).limit(5) + ).all() + return {"stats": stats, "recentLeads": [lead_dict(lead) for lead in recent]} + + +@router.get("/products") +def list_products( + keyword: str | None = None, + status_value: str | None = Query(default=None, alias="status"), + take: int = Query(default=100, ge=1, le=200), + _user: AdminUser = Depends(require_admin), + db: Session = Depends(get_db), +): + query = AdminProductQuery(keyword=keyword, status=status_value, take=take) + stmt = select(Product).options(selectinload(Product.destination), selectinload(Product.images)).order_by(Product.sortWeight.asc(), Product.updatedAt.desc()).limit(query.take) + if query.status: + stmt = stmt.where(Product.status == query.status) + if query.keyword: + pattern = f"%{query.keyword}%" + stmt = stmt.where(or_(Product.title.ilike(pattern), Product.subtitle.ilike(pattern), Product.tags.any(query.keyword))) + products = db.scalars(stmt).unique().all() + return {"items": [product_dict(product) for product in products]} + + +@router.post("/products", status_code=status.HTTP_201_CREATED) +def create_product(body: ProductCreateIn, request: Request, _user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)): + images = normalize_images(body.images) + sections = normalize_detail_sections(body.detailSections) + payload = body.model_dump(exclude={"images", "detailSections"}) + payload["priceUnit"] = body.priceUnit or "起/人" + payload["detailSections"] = sections or None + payload["publishedAt"] = utc_now() if body.status == "published" else None + product = Product(**payload) + db.add(product) + db.flush() + for image in images: + db.add(ProductImage(productId=product.id, **image)) + create_media(db, image["url"], "product-detail", image["alt"] or product.title) + db.flush() + product = load_product(db, product.id) + audit(db, get_actor_id(request), "create", "product", product.id, product_dict(product)) + db.commit() + return product_dict(product) + + +@router.patch("/products/{product_id}") +def update_product(product_id: str, body: ProductUpdateIn, request: Request, _user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)): + product = db.scalars(select(Product).options(selectinload(Product.images), selectinload(Product.destination)).where(Product.id == product_id)).first() + if not product: + raise HTTPException(status_code=404, detail="线路不存在") + before = product_dict(product) + fields = body.model_fields_set + payload = body.model_dump(exclude={"images", "detailSections"}, exclude_unset=True) + for key, value in payload.items(): + setattr(product, key, value) + if "detailSections" in fields: + product.detailSections = normalize_detail_sections(body.detailSections) + if "status" in fields and body.status == "published" and before.get("status") != "published": + product.publishedAt = utc_now() + if "images" in fields: + db.execute(delete(ProductImage).where(ProductImage.productId == product.id)) + for image in normalize_images(body.images): + db.add(ProductImage(productId=product.id, **image)) + create_media(db, image["url"], "product-detail", image["alt"] or product.title) + db.flush() + product = load_product(db, product.id) + audit(db, get_actor_id(request), "update", "product", product.id, product_dict(product), before) + db.commit() + return product_dict(product) + + +@router.get("/destinations") +def admin_destinations(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)): + destinations = db.scalars( + select(Destination).options(selectinload(Destination.aliases), selectinload(Destination.products)).order_by(Destination.sortOrder.asc()) + ).all() + return {"items": [destination_dict(destination, include_count=True) for destination in destinations]} + + +@router.get("/site-config") +def admin_site_config(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)): + return site_config(db, active_only=False, include_public_extras=False) + + +@router.patch("/site-config/{module}/{item_id}") +def update_site_config( + module: str, + item_id: str, + body: SiteConfigPatchIn, + request: Request, + _user: AdminUser = Depends(require_admin), + db: Session = Depends(get_db), +): + model_map = {"heroSlides": HeroSlide, "destinations": Destination, "themes": ThemeCard, "ctaBanners": CtaBanner} + if module not in model_map: + raise HTTPException(status_code=400, detail="维护模块不存在") + item = db.get(model_map[module], item_id) + if not item: + raise HTTPException(status_code=404, detail="维护项不存在") + before = model_dict(item) + fields = body.model_fields_set + + def set_if_present(field: str, attr: str | None = None, ignore_none: bool = False) -> None: + if field not in fields: + return + value = getattr(body, field) + if ignore_none and value is None: + return + setattr(item, attr or field, value) + + if module == "heroSlides": + set_if_present("title") + set_if_present("kicker") + set_if_present("image", ignore_none=True) + set_if_present("targetType") + set_if_present("targetValue") + set_if_present("isActive") + entity = "hero_slide" + elif module == "destinations": + set_if_present("name") + set_if_present("image", ignore_none=True) + set_if_present("isActive") + entity = "destination" + elif module == "themes": + set_if_present("label") + set_if_present("image", ignore_none=True) + set_if_present("targetType") + set_if_present("targetValue") + set_if_present("isActive") + entity = "theme_card" + else: + set_if_present("alt") + set_if_present("image", ignore_none=True) + set_if_present("targetType", ignore_none=True) + set_if_present("targetValue") + set_if_present("isActive") + entity = "cta_banner" + + db.flush() + audit(db, get_actor_id(request), "update", entity, item.id, model_dict(item), before) + db.commit() + return model_dict(item) + + +@router.get("/leads") +def list_leads( + status_value: str | None = Query(default=None, alias="status"), + take: int = Query(default=100, ge=1, le=200), + _user: AdminUser = Depends(require_admin), + db: Session = Depends(get_db), +): + query = LeadQuery(status=status_value, take=take) + stmt = ( + select(Lead) + .options(selectinload(Lead.sourceProduct), selectinload(Lead.assignedUser)) + .order_by(Lead.createdAt.desc()) + .limit(query.take) + ) + if query.status: + stmt = stmt.where(Lead.status == query.status) + leads = db.scalars(stmt).all() + return {"items": [lead_dict(lead) for lead in leads]} + + +@router.patch("/leads/{lead_id}/status") +def update_lead_status(lead_id: str, body: LeadStatusIn, request: Request, _user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)): + lead = db.get(Lead, lead_id) + if not lead: + raise HTTPException(status_code=404, detail="线索不存在") + before = model_dict(lead) + lead.status = body.status + db.flush() + audit(db, get_actor_id(request), "update_status", "lead", lead.id, model_dict(lead), before) + db.commit() + return model_dict(lead) + + +@router.get("/media-assets") +def list_media_assets(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)): + assets = db.scalars(select(MediaAsset).order_by(MediaAsset.createdAt.desc()).limit(200)).all() + return {"items": [model_dict(asset) for asset in assets]} + + +@router.post("/reset-guizhou-content") +def reset_content(request: Request, _user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)): + result = reset_guizhou_content(db) + audit(db, get_actor_id(request), "reset_guizhou_content", "site_content", after=result) + db.commit() + return result + + +@router.post("/publish", status_code=status.HTTP_201_CREATED) +def publish(request: Request, _user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)): + snapshot = site_config(db, active_only=True) + version = SiteVersion(title=f"manual-{utc_now().isoformat()}", status="published", snapshot=snapshot, publishedAt=utc_now()) + db.add(version) + db.flush() + audit(db, get_actor_id(request), "publish", "site_version", version.id, model_dict(version)) + db.commit() + return model_dict(version) diff --git a/app/routers/public.py b/app/routers/public.py new file mode 100644 index 0000000..f25a318 --- /dev/null +++ b/app/routers/public.py @@ -0,0 +1,92 @@ +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import or_, select +from sqlalchemy.orm import Session, selectinload +from .shared import site_config +from ..database import get_db +from ..models import Destination, DestinationAlias, Lead, Product +from ..schemas import LeadCreateIn, ProductQuery +from ..serializers import destination_dict, product_dict + + +router = APIRouter() + + +@router.get("/health") +def health(): + return {"ok": True, "service": "miniapp-api"} + + +@router.get("/api/public/site-config") +def get_site_config(db: Session = Depends(get_db)): + return site_config(db, active_only=True) + + +@router.get("/api/public/products") +def list_products( + keyword: str | None = None, + destinationId: str | None = None, + status_value: str = Query(default="published", alias="status"), + take: int = Query(default=48, ge=1, le=100), + db: Session = Depends(get_db), +): + query = ProductQuery(keyword=keyword, destinationId=destinationId, status=status_value, take=take) + stmt = ( + select(Product) + .options(selectinload(Product.destination).selectinload(Destination.aliases), selectinload(Product.images)) + .where(Product.status == query.status) + .order_by(Product.sortWeight.asc(), Product.createdAt.asc()) + .limit(query.take) + ) + if query.destinationId: + stmt = stmt.where(Product.destinationId == query.destinationId) + if query.keyword: + pattern = f"%{query.keyword}%" + stmt = ( + stmt.outerjoin(Product.destination) + .outerjoin(Destination.aliases) + .where( + or_( + Product.title.ilike(pattern), + Product.subtitle.ilike(pattern), + Product.tags.any(query.keyword), + Destination.name.ilike(pattern), + DestinationAlias.alias.ilike(pattern), + ) + ) + .distinct() + ) + products = db.scalars(stmt).unique().all() + return {"items": [product_dict(product) for product in products]} + + +@router.get("/api/public/products/{product_id}") +def get_product(product_id: str, db: Session = Depends(get_db)): + stmt = select(Product).options(selectinload(Product.destination), selectinload(Product.images)) + if product_id.isdigit(): + stmt = stmt.where(Product.sourceId == int(product_id)) + else: + stmt = stmt.where(Product.id == product_id) + product = db.scalars(stmt).first() + if not product: + raise HTTPException(status_code=404, detail="线路不存在") + return product_dict(product) + + +@router.get("/api/public/destinations") +def list_destinations(db: Session = Depends(get_db)): + destinations = db.scalars( + select(Destination) + .options(selectinload(Destination.aliases)) + .where(Destination.isActive.is_(True)) + .order_by(Destination.sortOrder.asc()) + ).all() + return {"items": [destination_dict(destination) for destination in destinations]} + + +@router.post("/api/public/leads", status_code=status.HTTP_201_CREATED) +def create_lead(body: LeadCreateIn, db: Session = Depends(get_db)): + lead = Lead(**body.model_dump(exclude_none=True)) + db.add(lead) + db.commit() + db.refresh(lead) + return {"id": lead.id, "status": lead.status} diff --git a/app/routers/shared.py b/app/routers/shared.py new file mode 100644 index 0000000..0de7b89 --- /dev/null +++ b/app/routers/shared.py @@ -0,0 +1,45 @@ +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload +from ..models import Campaign, CtaBanner, Destination, HeroSlide, Product, ThemeCard +from ..serializers import destination_dict, model_dict + + +ROUTE_SECTION_LABELS = [ + ("routes", "经典人文打卡线路", 0, 8), + ("routes-outdoor", "极限山野户外野咖线路", 8, 16), + ("routes-mix", "人文+户外综合混搭线路", 16, 24), +] + + +def site_config(db: Session, active_only: bool, include_public_extras: bool = True) -> dict: + hero_stmt = select(HeroSlide).order_by(HeroSlide.sortOrder.asc()) + destination_stmt = select(Destination).options(selectinload(Destination.aliases)).order_by(Destination.sortOrder.asc()) + theme_stmt = select(ThemeCard).order_by(ThemeCard.sortOrder.asc()) + cta_stmt = select(CtaBanner).order_by(CtaBanner.sortOrder.asc()) + if active_only: + hero_stmt = hero_stmt.where(HeroSlide.isActive.is_(True)) + destination_stmt = destination_stmt.where(Destination.isActive.is_(True)) + theme_stmt = theme_stmt.where(ThemeCard.isActive.is_(True)) + cta_stmt = cta_stmt.where(CtaBanner.isActive.is_(True)) + + hero_slides = db.scalars(hero_stmt).all() + destinations = db.scalars(destination_stmt).all() + themes = db.scalars(theme_stmt).all() + cta_banners = db.scalars(cta_stmt).all() + result = { + "heroSlides": [model_dict(item) for item in hero_slides], + "destinations": [destination_dict(item) for item in destinations], + "themes": [model_dict(item) for item in themes], + "ctaBanners": [model_dict(item) for item in cta_banners], + } + if include_public_extras: + campaigns = db.scalars(select(Campaign).where(Campaign.status == "published").order_by(Campaign.updatedAt.desc())).all() + products = db.scalars( + select(Product).where(Product.status == "published").order_by(Product.sortWeight.asc(), Product.createdAt.asc()).limit(48) + ).all() + result["campaigns"] = [model_dict(item) for item in campaigns] + result["routeSections"] = [ + {"id": section_id, "title": title, "productIds": [item.id for item in products[start:end]]} + for section_id, title, start, end in ROUTE_SECTION_LABELS + ] + return result diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..19b96e3 --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,113 @@ +from datetime import datetime +from typing import Literal +from pydantic import BaseModel, EmailStr, Field, field_validator + + +class LoginIn(BaseModel): + email: EmailStr + password: str = Field(min_length=6) + + +class ProductImageIn(BaseModel): + url: str = Field(min_length=1) + alt: str | None = None + sortOrder: int = 0 + + +class TextBlock(BaseModel): + type: Literal["text"] + text: str = Field(min_length=1) + + +class ImageBlock(BaseModel): + type: Literal["image"] + url: str = Field(min_length=1) + alt: str | None = None + + +class ProductDetailSectionIn(BaseModel): + key: str = Field(min_length=1) + label: str = Field(min_length=1) + title: str | None = None + blocks: list[TextBlock | ImageBlock] = [] + + +class ProductCreateIn(BaseModel): + title: str = Field(min_length=2) + subtitle: str | None = None + destinationId: str | None = None + priceAmount: int | None = Field(default=None, ge=0) + priceUnit: str | None = None + tags: list[str] = [] + coverImage: str | None = None + summary: str | None = None + images: list[ProductImageIn] | None = None + detailSections: list[ProductDetailSectionIn] | None = None + status: Literal["draft", "published", "archived"] = "draft" + sortWeight: int = 0 + + +class ProductUpdateIn(BaseModel): + title: str | None = Field(default=None, min_length=2) + subtitle: str | None = None + destinationId: str | None = None + priceAmount: int | None = Field(default=None, ge=0) + priceUnit: str | None = None + tags: list[str] | None = None + coverImage: str | None = None + summary: str | None = None + images: list[ProductImageIn] | None = None + detailSections: list[ProductDetailSectionIn] | None = None + status: Literal["draft", "published", "archived"] | None = None + sortWeight: int | None = None + + +class LeadCreateIn(BaseModel): + destination: str | None = None + phone: str = Field(min_length=2, max_length=64) + travelDate: datetime | None = None + peopleCount: int | None = Field(default=None, gt=0) + budgetMin: int | None = Field(default=None, ge=0) + budgetMax: int | None = Field(default=None, ge=0) + note: str | None = Field(default=None, max_length=1000) + sourcePage: str | None = None + sourceProductId: str | None = None + + @field_validator("phone") + @classmethod + def normalize_phone(cls, value: str) -> str: + return " ".join(value.strip().split()) + + +class LeadStatusIn(BaseModel): + status: Literal["new", "assigned", "contacted", "planning", "won", "invalid"] + + +class ProductQuery(BaseModel): + keyword: str | None = None + destinationId: str | None = None + status: str = "published" + take: int = Field(default=48, ge=1, le=100) + + +class AdminProductQuery(BaseModel): + keyword: str | None = None + status: str | None = None + take: int = Field(default=100, ge=1, le=200) + + +class LeadQuery(BaseModel): + status: str | None = None + take: int = Field(default=100, ge=1, le=200) + + +class SiteConfigPatchIn(BaseModel): + title: str | None = None + kicker: str | None = None + name: str | None = None + label: str | None = None + alt: str | None = None + image: str | None = None + targetType: str | None = None + targetValue: str | None = None + isActive: bool | None = None diff --git a/app/seed.py b/app/seed.py new file mode 100644 index 0000000..0d7bf76 --- /dev/null +++ b/app/seed.py @@ -0,0 +1,237 @@ +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from urllib.parse import quote +from sqlalchemy import delete, select +from sqlalchemy.orm import Session +from .auth import hash_password +from .content import ALIASES, CAMPAIGNS, CTAS, DESTINATIONS, HERO_SLIDES, THEMES +from .database import Base, SessionLocal, engine +from .models import ( + AdminUser, + Campaign, + CampaignProduct, + CtaBanner, + Destination, + DestinationAlias, + HeroSlide, + MediaAsset, + Product, + ProductImage, + SiteVersion, + ThemeCard, + utc_now, +) + + +ROOT_DIR = Path(__file__).resolve().parent.parent + + +def slugify(value: str) -> str: + return quote(value, safe="").replace("%", "").lower() + + +def create_media(db: Session, url: str | None, group: str, name: str | None = None) -> None: + if not url: + return + media = db.scalar(select(MediaAsset).where(MediaAsset.url == url)) + if media: + media.group = group + media.name = name + else: + db.add(MediaAsset(url=url, group=group, name=name)) + + +def default_detail_sections(summary: str, location: str) -> list[dict]: + return [ + { + "key": "overview", + "label": "行程概述", + "title": "小包团专属概览", + "blocks": [{"type": "text", "text": f"{summary}。万趣会按同行人、预算、酒店偏好和体力强度重排细节,保留小车小团、错峰入园与在地向导服务。"}], + }, + { + "key": "itinerary", + "label": "每日行程", + "title": f"{location} 弹性安排", + "blocks": [{"type": "text", "text": "默认按抵达接站、核心景点游览、特色体验、酒店休整和返程送站安排每日节奏;具体天数、停留时长和餐食可在行前由服务管家二次确认。"}], + }, + { + "key": "service", + "label": "包含/不含服务", + "title": "费用边界清晰", + "blocks": [{"type": "text", "text": "通常包含当地用车、行程内住宿、列明门票/体验、必要讲解和服务管家跟进;大交通、个人消费、未列明餐食和自选项目以最终方案为准。"}], + }, + { + "key": "notice", + "label": "出行须知", + "title": "贵州山地旅行提示", + "blocks": [{"type": "text", "text": "贵州多山多雨,建议准备防滑鞋、轻便雨具和薄外套;溶洞、漂流、徒步等体验会按天气和同行人体力调整。"}], + }, + { + "key": "price", + "label": "价格区间", + "title": "按人数、酒店和季节报价", + "blocks": [{"type": "text", "text": "页面价格为参考起价,节假日、旺季房态、用车车型和体验资源会影响最终报价;提交需求后由服务管家给出可执行方案。"}], + }, + { + "key": "manager", + "label": "服务管家", + "title": "直接添加服务管家", + "blocks": [{"type": "text", "text": "点击底部“服务管家”或拨打 18786174929,可直接添加服务管家沟通出行人数、日期、酒店偏好和预算。"}], + }, + ] + + +def product_subtitle(title: str) -> str: + return re.sub(r"^【.*?】\s*", "", title).split("·")[0] + + +def load_products() -> list[dict]: + with (ROOT_DIR / "data" / "generated-products.json").open(encoding="utf-8") as handle: + return json.load(handle) + + +def reset_guizhou_content(db: Session) -> dict: + products = load_products() + for model in [SiteVersion, CampaignProduct, Campaign, ProductImage, Product, CtaBanner, ThemeCard, HeroSlide, DestinationAlias, Destination, MediaAsset]: + db.execute(delete(model)) + db.flush() + + for index, slide in enumerate(HERO_SLIDES): + create_media(db, slide["image"], "hero", slide["title"]) + db.add( + HeroSlide( + title=slide["title"], + kicker=slide["kicker"], + actionLabel=slide["action"], + image=slide["image"], + targetType="campaign", + targetValue=slide["targetValue"], + sortOrder=index, + ) + ) + + destination_map: dict[str, str] = {} + for index, (name, image) in enumerate(DESTINATIONS): + create_media(db, image, "destination", name) + destination = Destination(name=name, slug=slugify(name), image=image, isHot=index < 8, sortOrder=index) + db.add(destination) + db.flush() + destination_map[name] = destination.id + for alias in ALIASES.get(name, []): + db.add(DestinationAlias(destinationId=destination.id, alias=alias)) + + for index, (label, image) in enumerate(THEMES): + create_media(db, image, "theme", label) + db.add(ThemeCard(label=label, image=image, targetType="search", targetValue=label, sortOrder=index)) + + for index, (alt, image, target_type, target_value) in enumerate(CTAS): + create_media(db, image, "cta", alt) + db.add(CtaBanner(alt=alt, image=image, targetType=target_type, targetValue=target_value, sortOrder=index)) + + for product in products: + create_media(db, product.get("image"), "product", product["title"]) + matched_destination = product.get("destinationName") if product.get("destinationName") in destination_map else None + if not matched_destination: + matched_destination = next( + (name for name in destination_map if name in product["title"] or name in product.get("tags", [])), + None, + ) + summary = product.get("summary") or " · ".join(product.get("tags", [])) + created = Product( + sourceId=product["id"], + title=product["title"], + subtitle=product_subtitle(product["title"]), + destinationId=destination_map.get(matched_destination) if matched_destination else None, + priceAmount=int(product["price"]), + tags=product.get("tags", []), + coverImage=product.get("image"), + summary=summary, + detailSections=default_detail_sections(summary, matched_destination or "贵州省内定制"), + status="published", + sortWeight=product["id"], + publishedAt=utc_now(), + ) + db.add(created) + db.flush() + db.add(ProductImage(productId=created.id, url=product["image"], alt=product["title"])) + + for seed in CAMPAIGNS: + campaign = Campaign( + slug=seed["slug"], + title=seed["title"], + description="万趣贵州小包团活动专题。", + coverImage=seed["coverImage"], + status="published", + ) + db.add(campaign) + db.flush() + linked_products = db.scalars( + select(Product) + .where(Product.sourceId >= seed["start"] + 1, Product.sourceId <= seed["end"]) + .order_by(Product.sourceId.asc()) + ).all() + for index, product in enumerate(linked_products): + db.add(CampaignProduct(campaignId=campaign.id, productId=product.id, sortOrder=index)) + + snapshot = SiteVersion( + title="guizhou-content-reset", + status="published", + publishedAt=utc_now(), + snapshot={ + "heroSlides": len(HERO_SLIDES), + "destinations": len(DESTINATIONS), + "themeCards": len(THEMES), + "products": len(products), + }, + ) + db.add(snapshot) + db.flush() + return { + "heroSlides": len(HERO_SLIDES), + "destinations": len(DESTINATIONS), + "themes": len(THEMES), + "ctaBanners": len(CTAS), + "products": len(products), + "siteVersionId": snapshot.id, + } + + +def seed_database(reset: bool) -> dict: + Base.metadata.create_all(bind=engine) + with SessionLocal() as db: + user = db.scalar(select(AdminUser).where(AdminUser.email == "admin@example.com")) + if user: + user.passwordHash = hash_password("ChangeMe123!") + user.isActive = True + user.name = "后台管理员" + user.role = "super_admin" + else: + db.add( + AdminUser( + email="admin@example.com", + name="后台管理员", + passwordHash=hash_password("ChangeMe123!"), + role="super_admin", + ) + ) + result = reset_guizhou_content(db) if reset else {"reset": False} + db.commit() + return result + + +def main() -> None: + parser = argparse.ArgumentParser(description="Seed WonderQ Admin database") + parser.add_argument("--no-reset", action="store_true", help="Only ensure admin user exists") + args = parser.parse_args() + result = seed_database(reset=not args.no_reset) + print(f"Seed complete. Admin login: admin@example.com / ChangeMe123!") + print(json.dumps(result, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/app/serializers.py b/app/serializers.py new file mode 100644 index 0000000..37c28ee --- /dev/null +++ b/app/serializers.py @@ -0,0 +1,47 @@ +from datetime import datetime +from sqlalchemy.inspection import inspect + + +def encode_value(value): + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, list): + return [encode_value(item) for item in value] + if isinstance(value, dict): + return {key: encode_value(item) for key, item in value.items()} + return value + + +def model_dict(instance, include: dict[str, object] | None = None) -> dict: + data = {column.key: encode_value(getattr(instance, column.key)) for column in inspect(instance).mapper.column_attrs} + if include: + for key, value in include.items(): + data[key] = encode_value(value) + return data + + +def product_dict(product) -> dict: + return model_dict( + product, + { + "destination": model_dict(product.destination) if product.destination else None, + "images": [model_dict(image) for image in sorted(product.images, key=lambda item: item.sortOrder)], + }, + ) + + +def destination_dict(destination, include_count: bool = False) -> dict: + data = model_dict(destination, {"aliases": [model_dict(alias) for alias in destination.aliases]}) + if include_count: + data["_count"] = {"products": len(destination.products)} + return data + + +def lead_dict(lead) -> dict: + return model_dict( + lead, + { + "sourceProduct": {"id": lead.sourceProduct.id, "title": lead.sourceProduct.title} if lead.sourceProduct else None, + "assignedUser": {"id": lead.assignedUser.id, "name": lead.assignedUser.name} if lead.assignedUser else None, + }, + ) diff --git a/data/generated-products.json b/data/generated-products.json new file mode 100644 index 0000000..d7f6767 --- /dev/null +++ b/data/generated-products.json @@ -0,0 +1,290 @@ +[ + { + "id": 1, + "title": "【经典人文打卡】贵阳+黄果树+荔波小七孔+西江苗寨+梵净山7天6晚·小包团首游环线", + "price": "16800", + "tags": [ + "经典人文打卡", + "小包团" + ], + "image": "/assets/guizhou/huangguoshu-waterfall.jpg", + "summary": "贵州首游代表线路,串联瀑布、绿宝石河谷、苗寨夜景和梵净山云海。", + "destinationName": "黄果树" + }, + { + "id": 2, + "title": "【贵阳慢游】甲秀楼+青岩古镇+城市美食+黄果树4天3晚·轻松打卡不赶路", + "price": "8600", + "tags": [ + "经典人文打卡", + "贵阳安顺" + ], + "image": "/assets/guizhou/jiaxiu-tower.jpg", + "summary": "适合周末或短假,从城市地标、古镇风味一路延展到黄果树瀑布。", + "destinationName": "贵阳" + }, + { + "id": 3, + "title": "【绿宝石秘境】荔波小七孔+茂兰森林+水春河4天3晚·河谷轻徒步与在地美食", + "price": "9800", + "tags": [ + "经典人文打卡", + "风光地貌" + ], + "image": "/assets/guizhou/libo-xiaoqikong.jpg", + "summary": "把小七孔、水上森林、茂兰喀斯特和黔南风味安排进一段清凉旅程。", + "destinationName": "荔波小七孔" + }, + { + "id": 4, + "title": "【苗岭入画】西江千户苗寨+镇远古城+非遗苗绣5天4晚·夜景与手作体验", + "price": "11800", + "tags": [ + "经典人文打卡", + "非遗体验" + ], + "image": "/assets/guizhou/xijiang-miao-village.jpg", + "summary": "深入苗寨街巷、古城夜色和非遗手作,保留小团讲解与拍摄节奏。", + "destinationName": "西江苗寨" + }, + { + "id": 5, + "title": "【云上铜仁】梵净山+云舍村+寨沙侗寨4天3晚·错峰登山与山地慢游", + "price": "12800", + "tags": [ + "经典人文打卡", + "自然奇景" + ], + "image": "/assets/guizhou/fanjing-mountain.jpg", + "summary": "围绕梵净山云海、村寨慢游和当地风味,安排更稳妥的登山窗口。", + "destinationName": "梵净山" + }, + { + "id": 6, + "title": "【峰林田园】兴义万峰林+马岭河峡谷+布依村寨5天4晚·峰林观景与家宴", + "price": "10800", + "tags": [ + "经典人文打卡", + "黔西南" + ], + "image": "/assets/guizhou/wanfenglin.jpg", + "summary": "在峰林、峡谷和布依村寨之间切换,适合摄影、亲友小团和慢旅行。", + "destinationName": "万峰林" + }, + { + "id": 7, + "title": "【侗寨清音】肇兴侗寨+堂安梯田+加榜梯田5天4晚·侗族大歌与梯田日落", + "price": "11600", + "tags": [ + "经典人文打卡", + "侗族大歌" + ], + "image": "/assets/guizhou/dong-village.jpg", + "summary": "从侗寨鼓楼、梯田日落到侗族大歌,适合人文摄影和村寨慢行。", + "destinationName": "肇兴侗寨" + }, + { + "id": 8, + "title": "【黔北记忆】遵义+娄山关+赤水丹霞5天4晚·红色文化与世界自然遗产", + "price": "12600", + "tags": [ + "经典人文打卡", + "红色文化" + ], + "image": "/assets/guizhou/chishui-danxia.jpg", + "summary": "把娄山关战役、遵义记忆和赤水丹霞组合成更有层次的黔北线路。", + "destinationName": "赤水丹霞" + }, + { + "id": 9, + "title": "【地心探秘】十二背后+织金洞+石龙洞5天4晚·天然溶洞探险与山野咖啡", + "price": "13800", + "tags": [ + "极限山野户外野咖", + "溶洞探险" + ], + "image": "/assets/guizhou/zhijin-cave.jpg", + "summary": "以溶洞、地下河和喀斯特地貌为主线,搭配轻户外装备和野咖停靠。", + "destinationName": "织金洞" + }, + { + "id": 10, + "title": "【河谷轻户外】荔波小七孔+水春河漂流+茂兰森林5天4晚·清凉水线玩法", + "price": "12800", + "tags": [ + "极限山野户外野咖", + "漂流" + ], + "image": "/assets/guizhou/shuichunhe-rafting.jpg", + "summary": "适合夏季出行,围绕河谷、森林、漂流和轻徒步设计每日节奏。", + "destinationName": "荔波小七孔" + }, + { + "id": 11, + "title": "【峰林野咖】万峰林骑行+马岭河峡谷+穹岛咖啡5天4晚·轻骑行与日落", + "price": "11800", + "tags": [ + "极限山野户外野咖", + "山野咖啡" + ], + "image": "/assets/guizhou/wanfenglin.jpg", + "summary": "在峰林公路、峡谷观景和山野咖啡之间安排松弛感十足的小团体验。", + "destinationName": "万峰林" + }, + { + "id": 12, + "title": "【高原花海】乌蒙草原+百里杜鹃+山野咖啡4天3晚·草原风和花季限定", + "price": "9600", + "tags": [ + "极限山野户外野咖", + "季节限定" + ], + "image": "/assets/guizhou/baili-azalea.jpg", + "summary": "花季、草原、山地咖啡和轻徒步组合,适合家庭或好友短线。", + "destinationName": "百里杜鹃" + }, + { + "id": 13, + "title": "【酷玩森林】酷玩森林+罗秧河+莲花古洞4天3晚·轻探险与亲友小队", + "price": "10600", + "tags": [ + "极限山野户外野咖", + "轻探险" + ], + "image": "/assets/guizhou/zhijin-cave.jpg", + "summary": "面向轻户外人群的可控强度玩法,保留安全边界和灵活休整。", + "destinationName": "十二背后" + }, + { + "id": 14, + "title": "【瀑布峡谷】黄果树+坝陵河+峡谷徒步4天3晚·桥梁观景与瀑布晨光", + "price": "9800", + "tags": [ + "极限山野户外野咖", + "峡谷徒步" + ], + "image": "/assets/guizhou/huangguoshu-waterfall.jpg", + "summary": "把黄果树瀑布、坝陵河桥梁景观和峡谷轻徒步做成短假小包团。", + "destinationName": "黄果树" + }, + { + "id": 15, + "title": "【温泉山行】剑河温泉+苗岭轻徒步+翁布12店4天3晚·泡汤与山野停靠", + "price": "10800", + "tags": [ + "极限山野户外野咖", + "温泉康养" + ], + "image": "/assets/guizhou/jianhe-hot-spring.jpg", + "summary": "适合想轻松一点的户外行程,用温泉、村寨和山野咖啡平衡体力。", + "destinationName": "黔东南" + }, + { + "id": 16, + "title": "【赤水竹海】赤水丹霞+竹海徒步+野奢营地5天4晚·黔北山水轻野奢", + "price": "13600", + "tags": [ + "极限山野户外野咖", + "野奢酒店" + ], + "image": "/assets/guizhou/chishui-danxia.jpg", + "summary": "丹霞、竹海和山地住宿组合,适合避暑、摄影和轻徒步。", + "destinationName": "赤水丹霞" + }, + { + "id": 17, + "title": "【人文户外混搭】贵阳+黄果树+西江苗寨+野咖6天5晚·打卡与松弛同行", + "price": "14800", + "tags": [ + "人文+户外综合混搭", + "山野咖啡" + ], + "image": "/assets/guizhou/miao-dragon-boat.jpg", + "summary": "把城市、美食、瀑布、苗寨和山野停靠组合,适合第一次深度体验贵州。", + "destinationName": "西江苗寨" + }, + { + "id": 18, + "title": "【绿水侗寨】荔波小七孔+肇兴侗寨+加榜梯田6天5晚·自然与人文同程", + "price": "15200", + "tags": [ + "人文+户外综合混搭", + "侗寨梯田" + ], + "image": "/assets/guizhou/jiabang-terrace.jpg", + "summary": "从绿宝石水线到侗寨梯田,用轻徒步和非遗体验串起黔南黔东南。", + "destinationName": "肇兴侗寨" + }, + { + "id": 19, + "title": "【云海古城】梵净山+镇远古城+非遗蜡染5天4晚·登山、夜游与手作", + "price": "13900", + "tags": [ + "人文+户外综合混搭", + "非遗蜡染" + ], + "image": "/assets/guizhou/zhenyuan-ancient-town.jpg", + "summary": "把梵净山登临、镇远古城夜游和非遗蜡染体验放进同一条舒适动线。", + "destinationName": "镇远古城" + }, + { + "id": 20, + "title": "【苗寨峰林】西江苗寨+万峰林+马岭河峡谷7天6晚·村寨、峰林和峡谷", + "price": "16800", + "tags": [ + "人文+户外综合混搭", + "小包团" + ], + "image": "/assets/guizhou/miao-costume.jpg", + "summary": "兼顾苗寨文化、峰林田园和峡谷景观,适合时间充裕的小包团。", + "destinationName": "万峰林" + }, + { + "id": 21, + "title": "【城市温泉】甲秀楼+青岩古镇+黄果树柏联温泉4天3晚·经典酒店舒适版", + "price": "11800", + "tags": [ + "人文+户外综合混搭", + "经典酒店" + ], + "image": "/assets/guizhou/bailian-hot-spring.jpg", + "summary": "城市地标、古镇慢逛和黄果树温泉酒店组合,适合长辈与亲子。", + "destinationName": "贵阳" + }, + { + "id": 22, + "title": "【节庆苗乡】黔东南姊妹节+苗族服饰拍摄+长桌宴5天4晚·人文体验专线", + "price": "12800", + "tags": [ + "人文+户外综合混搭", + "特有人文体验" + ], + "image": "/assets/guizhou/miao-lusheng-dance.jpg", + "summary": "围绕节庆、服饰、银饰、歌舞和长桌宴,安排更沉浸的人文小团。", + "destinationName": "黔东南" + }, + { + "id": 23, + "title": "【花海洞天】百里杜鹃+织金洞+温泉度假5天4晚·花季和溶洞双主题", + "price": "12200", + "tags": [ + "人文+户外综合混搭", + "天然溶洞探险" + ], + "image": "/assets/guizhou/wumeng-grassland.jpg", + "summary": "在花季风光、溶洞景观和温泉休整之间找到轻松的节奏。", + "destinationName": "织金洞" + }, + { + "id": 24, + "title": "【黔北山水】赤水丹霞+娄山关+乌江寨苗王酒店5天4晚·红色文化与野奢住宿", + "price": "15600", + "tags": [ + "人文+户外综合混搭", + "野奢酒店" + ], + "image": "/assets/guizhou/chishui-danxia.jpg", + "summary": "把黔北红色文化、丹霞山水和特色住宿资源整合成更舒适的小包团。", + "destinationName": "赤水丹霞" + } +] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..23234f6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,50 @@ +services: + api: + build: . + container_name: wonderq-admin-api + restart: unless-stopped + environment: + DATABASE_URL: postgresql://miniapp:miniapp_dev_password@postgres:5432/miniapp + JWT_SECRET: replace-with-a-long-random-secret-before-production + PORT: 4000 + LOG_LEVEL: info + ports: + - "4000:4000" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_started + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://localhost:4000/health || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + + postgres: + image: postgres:16-alpine + container_name: wonderq-admin-postgres + restart: unless-stopped + environment: + POSTGRES_DB: miniapp + POSTGRES_USER: miniapp + POSTGRES_PASSWORD: miniapp_dev_password + ports: + - "5433:5432" + volumes: + - miniapp-postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U miniapp -d miniapp"] + interval: 5s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + container_name: wonderq-admin-redis + restart: unless-stopped + ports: + - "6380:6379" + +volumes: + miniapp-postgres-data: diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..6d743c5 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,8 @@ +# WonderQ-Admin 文档 + +本目录存放后端 API 相关文档。 + +- `backend/README.md`:当前 Python + FastAPI 后端运行说明。 +- `admin-backend-plan.md`:早期后台建设规划,保留作业务范围和阶段规划参考;其中技术栈建议已被当前 Python 迁移方案取代。 + +后台管理前端文档位于 `D:\www\znkj\WonderQ-Admin-UI\docs`。 diff --git a/docs/admin-backend-plan.md b/docs/admin-backend-plan.md new file mode 100644 index 0000000..a548afd --- /dev/null +++ b/docs/admin-backend-plan.md @@ -0,0 +1,36 @@ +# WonderQ-Admin 后端规划摘要 + +## 当前定位 + +`WonderQ-Admin` 是 WonderQ 的独立后端 API 服务,当前技术栈已调整为 Python + FastAPI + SQLAlchemy 2 + Alembic + PostgreSQL,通过 Docker Compose 部署 API、PostgreSQL 和 Redis。 + +## 核心目标 + +- 为 H5 前台提供稳定的 Public API。 +- 为后台管理端提供 JWT 鉴权的 Admin API。 +- 保留现有 PostgreSQL 数据和主要接口路径,降低前端联调成本。 +- 使用 Alembic 管理后续数据库迁移,已有数据库通过 `alembic stamp head` 接入 baseline。 + +## 当前业务模块 + +- 首页配置:轮播、目的地、主题卡片、底部 CTA、发布版本。 +- 线路产品:列表、详情、图片、详情区块、状态和排序。 +- 目的地:目的地基础信息、别名、热门状态。 +- 活动专题:活动和产品关联。 +- 线索:前台提交、后台列表、状态流转。 +- 媒体:媒体资源登记。 +- 审计:后台关键变更写入 `AuditLog`。 + +## 近期优先级 + +1. 保持 Public/Admin API 与现有前端调用兼容。 +2. 补充更多 PostgreSQL 集成测试,覆盖迁移、seed 和核心接口。 +3. 建立生产迁移流程:备份、`alembic stamp head`、后续增量迁移。 +4. 按实际业务继续扩展权限、订单、客户和消息模块。 + +## 安全与部署原则 + +- 生产环境必须替换 `JWT_SECRET`,禁止使用示例值。 +- 不提交 `.env`、日志、数据库备份和任何真实密钥。 +- `python -m app.seed` 会重置内容数据,生产环境执行前必须明确确认。 +- Docker Compose 适合本地和单机部署;生产可按相同环境变量拆分到托管数据库或容器平台。 diff --git a/docs/backend/README.md b/docs/backend/README.md new file mode 100644 index 0000000..a3467c8 --- /dev/null +++ b/docs/backend/README.md @@ -0,0 +1,25 @@ +# 后台 API 服务 + +`WonderQ-Admin` 当前是独立的 Python + FastAPI API 服务,使用 PostgreSQL 保存业务数据,通过 Docker Compose 启动 `api`、`postgres` 和 `redis`。 + +## 本地 API 启动 + +```bash +python -m venv .venv +.venv\Scripts\activate +pip install -r requirements.txt +docker compose up -d postgres redis +alembic upgrade head +python -m app.seed +uvicorn app.main:app --host 0.0.0.0 --port 4000 --reload +``` + +健康检查:`http://localhost:4000/health` + +## 前端联调 + +后台管理前端位于 `D:\www\znkj\WonderQ-Admin-UI`。如需连接本服务,在前端 `.env` 或环境变量中配置: + +```text +VITE_API_BASE_URL="http://localhost:4000" +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a4efe32 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "wonderq-admin" +version = "0.1.0" +description = "WonderQ Admin API service" +requires-python = ">=3.12,<3.15" +dependencies = [ + "fastapi>=0.115,<0.116", + "uvicorn[standard]>=0.32,<0.33", + "SQLAlchemy>=2.0,<2.1", + "alembic>=1.14,<1.15", + "psycopg[binary]>=3.2,<3.3", + "pydantic-settings>=2.6,<2.7", + "PyJWT>=2.10,<2.11", + "bcrypt>=4.2,<4.3", + "email-validator>=2.2,<2.3", +] + +[project.optional-dependencies] +test = [ + "pytest>=8.3,<8.4", + "httpx>=0.27,<0.28", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b116a52 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +fastapi>=0.115,<0.116 +uvicorn[standard]>=0.32,<0.33 +SQLAlchemy>=2.0,<2.1 +alembic>=1.14,<1.15 +psycopg[binary]>=3.2,<3.3 +pydantic-settings>=2.6,<2.7 +PyJWT>=2.10,<2.11 +bcrypt>=4.2,<4.3 +email-validator>=2.2,<2.3 +pytest>=8.3,<8.4 +httpx>=0.27,<0.28 diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..edfc76d --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,9 @@ +from fastapi.testclient import TestClient +from app.main import create_app + + +def test_health_endpoint(): + client = TestClient(create_app()) + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"ok": True, "service": "miniapp-api"} diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..03e7043 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,7 @@ +from app.auth import hash_password, verify_password + + +def test_bcrypt_hash_verifies_password(): + password_hash = hash_password("ChangeMe123!", rounds=4) + assert verify_password("ChangeMe123!", password_hash) + assert not verify_password("bad-password", password_hash) diff --git a/tests/test_schemas.py b/tests/test_schemas.py new file mode 100644 index 0000000..7521c73 --- /dev/null +++ b/tests/test_schemas.py @@ -0,0 +1,13 @@ +import pytest +from pydantic import ValidationError +from app.schemas import LeadCreateIn, ProductCreateIn + + +def test_lead_phone_is_normalized(): + lead = LeadCreateIn(phone=" 187 8617 4929 ") + assert lead.phone == "187 8617 4929" + + +def test_product_title_requires_two_chars(): + with pytest.raises(ValidationError): + ProductCreateIn(title="A")