Compare commits

..

1 Commits

Author SHA1 Message Date
陈世睿 87461d1dac feat(compare): 加电商比价透传路由
POST /api/v1/ecom/intent/recognize 和 /api/v1/ecom/step 透传到 pricebot-backend
的 /api/ecom/intent/recognize 和 /api/ecom/step。前端电商 mode 已经开放,
无这两条路由会 404;现在和外卖那两条对称。模块 docstring 改成「外卖+电商两套」。
2026-05-30 20:11:49 +08:00
9 changed files with 39 additions and 292 deletions
@@ -1,36 +0,0 @@
"""convert savings_record.dishes from json to jsonb
PG only. SQLite 上 JSON/JSONB 都是 TEXT, 此迁移是 no-op。
Revision ID: ef96beb47b1e
Revises: c8d9e0f1a2b3
Create Date: 2026-05-29 10:57:21.471774
"""
from typing import Sequence, Union
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB
revision: str = 'ef96beb47b1e'
down_revision: Union[str, Sequence[str], None] = 'c8d9e0f1a2b3'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
if op.get_bind().dialect.name != "postgresql":
return
op.execute(
"ALTER TABLE savings_record "
"ALTER COLUMN dishes TYPE JSONB USING dishes::JSONB"
)
def downgrade() -> None:
if op.get_bind().dialect.name != "postgresql":
return
op.execute(
"ALTER TABLE savings_record "
"ALTER COLUMN dishes TYPE JSON USING dishes::JSON"
)
+20 -7
View File
@@ -1,17 +1,20 @@
"""外卖比价业务透传端点。 """比价业务透传端点(外卖 + 电商两套)
把客户端的 POST /api/v1/intent/recognize 和 /api/v1/price/step 请求原样转发给 把客户端的 POST /api/v1/{intent/recognize, price/step, ecom/intent/recognize, ecom/step}
pricebot-backend 的 /api/intent/recognize 和 /api/price/step。MVP 阶段**不鉴权** 请求原样转发给 pricebot-backend 的对应路径。MVP 阶段**不鉴权**(同 coupon/step,
(同 coupon/step,见 docs/待办与技术债.md P1:device_id 透传区分设备,待补 JWT + 见 docs/待办与技术债.md P1:device_id 透传区分设备,待补 JWT + device_id↔user_id
device_id↔user_id 绑定后才能做用户级画像)。 绑定后才能做用户级画像)。
外卖(food):
- Phase 1 /intent/recognize:从源平台(淘宝闪购/美团/京东外卖)购物车页识别店名+菜品+ - Phase 1 /intent/recognize:从源平台(淘宝闪购/美团/京东外卖)购物车页识别店名+菜品+
价格,一次性。 价格,一次性。
- Phase 2 /price/step:多轮循环,在目标平台复现订单读到手价,直到 done。 - Phase 2 /price/step:多轮循环,在目标平台复现订单读到手价,直到 done。
电商(ecom):
- Phase 1 /ecom/intent/recognize:从源平台(淘宝/抖音/拼多多)SKU+详情页识别商品,一次性。
- Phase 2 /ecom/step:多轮循环,在目标平台搜索商品并采价,直到 done。
真正的目标驱动比价逻辑在 pricebot-backend(GoalEngine,另一个 repo),本接口只是透传壳。 真正的目标驱动比价逻辑在 pricebot-backend(GoalEngine,另一个 repo),本接口只是透传壳。
电商(ecom)那两个端点 food MVP 暂不需要,以后放开 scene 时再加 /ecom/intent/recognize
和 /ecom/step 两行即可。
pricebot 协议文档: pricebot 协议文档:
pricebot-backend/docs/main/02_api_protocol.md pricebot-backend/docs/main/02_api_protocol.md
@@ -86,3 +89,13 @@ async def intent_recognize(request: Request) -> dict[str, Any]:
@router.post("/price/step", summary="外卖比价 Phase 2 步进 (透传到 pricebot)") @router.post("/price/step", summary="外卖比价 Phase 2 步进 (透传到 pricebot)")
async def price_step(request: Request) -> dict[str, Any]: async def price_step(request: Request) -> dict[str, Any]:
return await _passthrough(request, "/api/price/step") return await _passthrough(request, "/api/price/step")
@router.post("/ecom/intent/recognize", summary="电商比价 Phase 1 意图识别 (透传到 pricebot)")
async def ecom_intent_recognize(request: Request) -> dict[str, Any]:
return await _passthrough(request, "/api/ecom/intent/recognize")
@router.post("/ecom/step", summary="电商比价 Phase 2 步进 (透传到 pricebot)")
async def ecom_step(request: Request) -> dict[str, Any]:
return await _passthrough(request, "/api/ecom/step")
-7
View File
@@ -9,7 +9,6 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from app.core.config import settings
from app.integrations.meituan import MeituanCpsError, get_referral_link, query_coupon from app.integrations.meituan import MeituanCpsError, get_referral_link, query_coupon
from app.schemas.meituan import ( from app.schemas.meituan import (
CouponCard, CouponCard,
@@ -28,8 +27,6 @@ router = APIRouter(prefix="/api/v1/meituan", tags=["meituan-cps"])
@router.post("/coupons", response_model=CouponListResponse, summary="券列表(为您推荐)") @router.post("/coupons", response_model=CouponListResponse, summary="券列表(为您推荐)")
def list_coupons(req: CouponListRequest) -> CouponListResponse: def list_coupons(req: CouponListRequest) -> CouponListResponse:
if not settings.mt_cps_configured:
return CouponListResponse(items=[], has_next=False, search_id=None)
logger.info("[coupons] lon=%.6f lat=%.6f topic=%s", req.longitude, req.latitude, req.list_topic_id) logger.info("[coupons] lon=%.6f lat=%.6f topic=%s", req.longitude, req.latitude, req.list_topic_id)
try: try:
raw = query_coupon( raw = query_coupon(
@@ -81,8 +78,6 @@ def _interleave(waimai: list[dict], daodian: list[dict]) -> list[CouponCard]:
@router.post("/feed", response_model=FeedResponse, summary="混合feed(外卖+到店交叉, 无限流)") @router.post("/feed", response_model=FeedResponse, summary="混合feed(外卖+到店交叉, 无限流)")
def feed(req: FeedRequest) -> FeedResponse: def feed(req: FeedRequest) -> FeedResponse:
if not settings.mt_cps_configured:
return FeedResponse(items=[], has_next=False, page=req.page)
page_idx = req.page - 1 page_idx = req.page - 1
lon, lat = req.longitude, req.latitude lon, lat = req.longitude, req.latitude
logger.info("[feed] page=%s lon=%.6f lat=%.6f", req.page, lon, lat) logger.info("[feed] page=%s lon=%.6f lat=%.6f", req.page, lon, lat)
@@ -113,8 +108,6 @@ def feed(req: FeedRequest) -> FeedResponse:
@router.post("/referral-link", response_model=ReferralLinkResponse, summary="换取推广链接(点抢时调)") @router.post("/referral-link", response_model=ReferralLinkResponse, summary="换取推广链接(点抢时调)")
def referral_link(req: ReferralLinkRequest) -> ReferralLinkResponse: def referral_link(req: ReferralLinkRequest) -> ReferralLinkResponse:
if not settings.mt_cps_configured:
return ReferralLinkResponse(link="", link_map={})
try: try:
raw = get_referral_link( raw = get_referral_link(
product_view_sign=req.product_view_sign, product_view_sign=req.product_view_sign,
-7
View File
@@ -53,18 +53,11 @@ class Settings(BaseSettings):
SMS_SEND_INTERVAL_SEC: int = 60 SMS_SEND_INTERVAL_SEC: int = 60
# ===== 美团联盟 CPS ===== # ===== 美团联盟 CPS =====
# 未配置时所有 /api/v1/meituan/* 接口 200 返空(优雅降级),不影响登录/领券等其他业务。
MT_CPS_APP_KEY: str = "" MT_CPS_APP_KEY: str = ""
MT_CPS_APP_SECRET: str = "" MT_CPS_APP_SECRET: str = ""
MT_CPS_HOST: str = "https://media.meituan.com" MT_CPS_HOST: str = "https://media.meituan.com"
MT_CPS_TIMEOUT_SEC: int = 15 MT_CPS_TIMEOUT_SEC: int = 15
MT_CPS_DEFAULT_SID: str = "sgbjia" MT_CPS_DEFAULT_SID: str = "sgbjia"
@property
def mt_cps_configured(self) -> bool:
"""美团 CPS 凭证齐全(缺则接口返空,而非 502)。"""
return bool(self.MT_CPS_APP_KEY and self.MT_CPS_APP_SECRET)
# ===== 微信支付(商家转账到零钱 / 提现)===== # ===== 微信支付(商家转账到零钱 / 提现)=====
# 真实凭证放 .env(已 gitignore),证书 .pem 放 secrets/。WECHAT_APP_ID 同时用于 # 真实凭证放 .env(已 gitignore),证书 .pem 放 secrets/。WECHAT_APP_ID 同时用于
# 微信登录(code 换 openid)与转账,必须与 App 端开放平台 appid 一致。 # 微信登录(code 换 openid)与转账,必须与 App 端开放平台 appid 一致。
+8 -14
View File
@@ -27,25 +27,19 @@ def _ensure_sqlite_dir(url: str) -> None:
_ensure_sqlite_dir(settings.DATABASE_URL) _ensure_sqlite_dir(settings.DATABASE_URL)
_is_sqlite = settings.DATABASE_URL.startswith("sqlite")
# SQLite 跨线程访问要 check_same_thread=False;PG/MySQL 不需要这个参数 # SQLite 跨线程访问要 check_same_thread=False;PG/MySQL 不需要这个参数
_connect_args: dict = {} _connect_args: dict = {}
if _is_sqlite: if settings.DATABASE_URL.startswith("sqlite"):
_connect_args["check_same_thread"] = False _connect_args["check_same_thread"] = False
_engine_kwargs: dict = { engine = create_engine(
"connect_args": _connect_args, settings.DATABASE_URL,
connect_args=_connect_args,
# echo 在 dev 下打 SQL,生产关掉 # echo 在 dev 下打 SQL,生产关掉
"echo": settings.APP_DEBUG and not settings.is_prod, echo=settings.APP_DEBUG and not settings.is_prod,
"future": True, future=True,
"pool_pre_ping": True, pool_pre_ping=True,
} )
# SQLite 用单文件不需要池;PG/MySQL 必须显式池化 + recycle 防 idle 断连
if not _is_sqlite:
_engine_kwargs.update(pool_size=10, max_overflow=20, pool_recycle=3600)
engine = create_engine(settings.DATABASE_URL, **_engine_kwargs)
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False, expire_on_commit=False) SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False, expire_on_commit=False)
+3 -4
View File
@@ -8,8 +8,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base from app.db.base import Base
@@ -29,8 +28,8 @@ class SavingsRecord(Base):
title: Mapped[str | None] = mapped_column(String(128), nullable=True) title: Mapped[str | None] = mapped_column(String(128), nullable=True)
# 店铺名(订单明细卡标题,如「窑鸡王(王府井店)」) # 店铺名(订单明细卡标题,如「窑鸡王(王府井店)」)
shop_name: Mapped[str | None] = mapped_column(String(128), nullable=True) shop_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
# 菜品名列表(JSONB),PG 上可建 GIN 索引,前 2 道直接展示,其余收进「还有 N 道菜」展开。 # 菜品名列表(JSON),前 2 道直接展示,其余收进「还有 N 道菜」展开。SQLite 存为 TEXT。
dishes: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list) dishes: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
# 来源:demo(演示) / compare(真实比价上报) # 来源:demo(演示) / compare(真实比价上报)
source: Mapped[str] = mapped_column(String(16), nullable=False, default="compare") source: Mapped[str] = mapped_column(String(16), nullable=False, default="compare")
+8 -25
View File
@@ -15,37 +15,20 @@ alembic upgrade head
--- ---
## 1. 首次初始化(clone 后) ## 1. 首次初始化(clone 后)
### 1.1 用 PostgreSQL(推荐,与生产一致)
前置:本机已装 PostgreSQL 16 + 知道 postgres 超级用户密码。
```bash ```bash
# (1) 装依赖(在你的虚拟环境里, 含 psycopg) # (1) 装依赖(在你的虚拟环境里)
pip install -e . pip install -e .
# (2) 一键建用户 + 建库 + 写 .env + 跑迁移 # (2) 配 .env(从模板复制,至少填 JWT_SECRET_KEY)
python scripts/init_postgres.py cp .env.example .env
# 按提示输入 PG 超级用户密码即可。脚本会:
# - 自动建业务用户 shaguabijia_app + 业务库 shaguabijia
# - 自动生成业务用户强密码,写入 .env 的 DATABASE_URL
# - 跑完 alembic upgrade head
# (3) 启动 # (3) SQLite 默认库需要 data/ 目录存在
./run.sh
```
可用环境变量提前指定(免交互):
```bash
PG_SUPER_PASS=xxx APP_DB_PASS=yyy python scripts/init_postgres.py
```
脚本幂等,重复跑会重置业务用户密码、跳过已存在的库。
### 1.2 用 SQLite(本地开发临时用,不接生产链路时)
```bash
pip install -e .
cp .env.example .env # 不动 DATABASE_URL, 默认 sqlite:///./data/app.db
mkdir -p data mkdir -p data
# (4) 跑迁移建表 —— 关键
alembic upgrade head alembic upgrade head
# (5) 启动(run.sh 会自动重跑 3+4,所以平时直接 ./run.sh 也行)
./run.sh ./run.sh
``` ```
> 也可以直接 `bash scripts/migrate.sh` 只做迁移、不启服务(部署/CI 用)。 > 也可以直接 `bash scripts/migrate.sh` 只做迁移、不启服务(部署/CI 用)。
-3
View File
@@ -17,9 +17,6 @@ dependencies = [
"sqlalchemy>=2.0.35", "sqlalchemy>=2.0.35",
"alembic>=1.13.3", "alembic>=1.13.3",
# PostgreSQL 驱动 (psycopg3, SQLAlchemy 2.0 时代默认, 不要再装 psycopg2)
"psycopg[binary]>=3.1",
# JWT 签名 / 校验 # JWT 签名 / 校验
"pyjwt[crypto]>=2.9.0", "pyjwt[crypto]>=2.9.0",
-189
View File
@@ -1,189 +0,0 @@
"""Bootstrap PostgreSQL: 建用户 + 建库 + 授权 + 写 .env + 跑迁移。
新机器初始化用。前置:已装 PostgreSQL 16 + 知道 postgres 超级用户密码。
用法:
python scripts/init_postgres.py
环境变量(可选,不填会交互式问):
PG_HOST PG 主机, 默认 localhost
PG_PORT PG 端口, 默认 5432
PG_SUPER_USER 超级用户, 默认 postgres
PG_SUPER_PASS 超级用户密码 (不填会 getpass 交互输入)
APP_DB_NAME 业务库名, 默认 shaguabijia
APP_DB_USER 业务用户名, 默认 shaguabijia_app
APP_DB_PASS 业务用户密码 (不填会自动生成强密码)
幂等:已存在的用户/库不会重复建,已配的 .env 会被覆盖前提示。
"""
from __future__ import annotations
import getpass
import os
import secrets
import subprocess
import sys
from pathlib import Path
try:
import psycopg
from psycopg import sql
except ImportError:
print("❌ 缺 psycopg。先跑: pip install -e .")
sys.exit(1)
ROOT = Path(__file__).resolve().parent.parent
ENV_FILE = ROOT / ".env"
ENV_EXAMPLE = ROOT / ".env.example"
def env_or_input(key: str, prompt: str, default: str = "", secret: bool = False) -> str:
val = os.environ.get(key, "").strip()
if val:
return val
if secret:
return getpass.getpass(f"{prompt}: ").strip()
suffix = f" [{default}]" if default else ""
raw = input(f"{prompt}{suffix}: ").strip()
return raw or default
def ensure_role(conn: psycopg.Connection, user: str, password: str) -> None:
# 注意: CREATE/ALTER USER 不支持参数化密码, 必须用 sql.Literal 内联
with conn.cursor() as cur:
cur.execute("SELECT 1 FROM pg_roles WHERE rolname=%s", (user,))
if cur.fetchone():
print(f" · 用户 {user} 已存在, 重置密码")
cur.execute(
sql.SQL("ALTER USER {} WITH PASSWORD {}").format(
sql.Identifier(user), sql.Literal(password)
)
)
else:
print(f" · 创建用户 {user}")
cur.execute(
sql.SQL("CREATE USER {} WITH PASSWORD {}").format(
sql.Identifier(user), sql.Literal(password)
)
)
def ensure_database(conn: psycopg.Connection, db: str, owner: str) -> None:
with conn.cursor() as cur:
cur.execute("SELECT 1 FROM pg_database WHERE datname=%s", (db,))
if cur.fetchone():
print(f" · 数据库 {db} 已存在")
return
print(f" · 创建数据库 {db} (owner={owner}, encoding=UTF8)")
cur.execute(
sql.SQL("CREATE DATABASE {} OWNER {} ENCODING 'UTF8'").format(
sql.Identifier(db), sql.Identifier(owner)
)
)
def grant_all(conn: psycopg.Connection, db: str, user: str) -> None:
with conn.cursor() as cur:
cur.execute(
sql.SQL("GRANT ALL PRIVILEGES ON DATABASE {} TO {}").format(
sql.Identifier(db), sql.Identifier(user)
)
)
def write_env(database_url: str) -> None:
if not ENV_FILE.exists():
if ENV_EXAMPLE.exists():
ENV_FILE.write_text(ENV_EXAMPLE.read_text(encoding="utf-8"), encoding="utf-8")
print(f" · 从 .env.example 复制出 .env")
else:
ENV_FILE.write_text("", encoding="utf-8")
lines = ENV_FILE.read_text(encoding="utf-8").splitlines()
new_lines = []
replaced = False
for line in lines:
if line.startswith("DATABASE_URL="):
old_url = line.split("=", 1)[1]
if old_url.strip() and old_url.strip() != database_url:
ans = input(f"\n.env 里已有 DATABASE_URL=\n {old_url}\n覆盖吗? [y/N]: ").strip().lower()
if ans != "y":
print(" · 保留原 DATABASE_URL")
new_lines.append(line)
replaced = True
continue
new_lines.append(f"DATABASE_URL={database_url}")
replaced = True
else:
new_lines.append(line)
if not replaced:
new_lines.append(f"DATABASE_URL={database_url}")
ENV_FILE.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
print(f" · 写入 .env -> DATABASE_URL")
def run_alembic_upgrade() -> bool:
print("\n[3/3] 跑 alembic upgrade head")
try:
subprocess.run(
[sys.executable, "-m", "alembic", "upgrade", "head"],
cwd=ROOT,
check=True,
)
return True
except subprocess.CalledProcessError as e:
print(f"❌ alembic 失败: {e}")
return False
def main() -> int:
print("=" * 60)
print("PostgreSQL 初始化脚本 — shaguabijia-app-server")
print("=" * 60)
host = env_or_input("PG_HOST", "PG host", "localhost")
port = env_or_input("PG_PORT", "PG port", "5432")
super_user = env_or_input("PG_SUPER_USER", "PG superuser", "postgres")
super_pass = env_or_input("PG_SUPER_PASS", "PG superuser password", secret=True)
db_name = env_or_input("APP_DB_NAME", "App database name", "shaguabijia")
db_user = env_or_input("APP_DB_USER", "App database user", "shaguabijia_app")
db_pass = os.environ.get("APP_DB_PASS", "").strip()
if not db_pass:
db_pass = secrets.token_urlsafe(32)
print(f" · 自动生成业务用户密码: {db_pass}")
print(f"\n[1/3] 连接 PG (host={host}:{port}, user={super_user})")
try:
conn = psycopg.connect(
host=host, port=int(port), user=super_user, password=super_pass,
dbname="postgres", autocommit=True,
)
except psycopg.OperationalError as e:
print(f"❌ 连不上 PG: {e}")
print(" 检查 1) PG 服务是否在跑 2) 超级用户密码是否正确 3) 端口防火墙是否放行")
return 1
print("\n[2/3] 建用户 + 建库 + 授权")
with conn:
ensure_role(conn, db_user, db_pass)
ensure_database(conn, db_name, db_user)
grant_all(conn, db_name, db_user)
database_url = f"postgresql+psycopg://{db_user}:{db_pass}@{host}:{port}/{db_name}"
write_env(database_url)
if not run_alembic_upgrade():
return 1
print("\n" + "=" * 60)
print("✅ 全部完成")
print(f" DATABASE_URL = {database_url}")
print(" 下一步: 启动服务 -> ./run.sh 或 uvicorn app.main:app --reload --port 8770")
print("=" * 60)
return 0
if __name__ == "__main__":
sys.exit(main())