Compare commits

..

1 Commits

Author SHA1 Message Date
chenshuobo b995ee8ada feat: 首页推荐 feed 实现 MVP 排序策略 + 标签格式化
排序策略 (app/core/ranking.py 新建):
- parse_sale_volume: "热销8.5万+" → 85000 整数销量
- get_distance_km: 距离统一(到店米 ÷1000), >50km 视为脏数据
- filter_items: 售价≤0 / 距离>8km / 脏数据剔除
- sort_by_sales: 按销量降序
- split_pages + shuffle_pages: 切页 + 页内 Fisher-Yates 打乱
- inject_billboard: 榜单商品 4 条/页注入前 5 页, 随机位置
- merge_category_pages: 外卖第K页 + 到店第K页合并后 shuffle

Feed 接口重写 (app/api/v1/meituan.py):
- 旧逻辑: 三页固定 listTopiId 主题轮, 美团给什么序就什么序
- 新逻辑: searchText="外卖"/"到店餐饮" + sortField=6 翻页 LBS 召回,
  4 任务并发(外卖召回 / 到店召回 / 外卖榜单 / 到店榜单),
  走完 ranking pipeline 后做坐标级内存缓存(5min TTL)
- 翻页上限 5 页/品类(性价比最高: 3.4s 拿到 238 条/品类)
- 直接调 _call 而非 query_coupon, 因为 searchId 翻页时不能传 pageNo

标签格式化 (app/schemas/meituan.py):
- price_label: "比日常团购省3.5元" → "比团购省 3.5 元"
- rank_label: "2小时北京外卖销量榜第1名" → "外卖榜第 1"
- rating_label: "4.6分" → "点评 4.6 分"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:13:12 +08:00
21 changed files with 353 additions and 723 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"
)
-44
View File
@@ -1,44 +0,0 @@
"""feedback table (用户帮助与反馈)
Revision ID: d1e2f3a4b5c6
Revises: c8d9e0f1a2b3
Create Date: 2026-05-28 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd1e2f3a4b5c6'
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:
op.create_table(
'feedback',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('contact', sa.String(length=128), nullable=False),
sa.Column('images', sa.JSON(), nullable=True),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
)
with op.batch_alter_table('feedback', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_feedback_user_id'), ['user_id'], unique=False)
batch_op.create_index(batch_op.f('ix_feedback_created_at'), ['created_at'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('feedback', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_feedback_created_at'))
batch_op.drop_index(batch_op.f('ix_feedback_user_id'))
op.drop_table('feedback')
-63
View File
@@ -1,63 +0,0 @@
"""帮助与反馈 endpoint。
路由前缀 `/api/v1/feedback`,需 Bearer 鉴权(反馈绑到登录用户,便于回访)。
POST / 提交反馈(multipart:content / contact 必填,images 可选 ≤4 张)
截图复用 [app.core.media] 落盘到 /media/feedback/。
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from app.api.deps import CurrentUser, DbSession
from app.core import media
from app.repositories import feedback as feedback_repo
from app.schemas.feedback import FeedbackOut
logger = logging.getLogger("shagua.feedback")
router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"])
_MAX_IMAGES = 4
_CONTENT_MAX = 2000
_CONTACT_MAX = 128
@router.post("", response_model=FeedbackOut, summary="提交反馈")
async def submit_feedback(
user: CurrentUser,
db: DbSession,
content: str = Form(...),
contact: str = Form(...),
images: list[UploadFile] = File(default=[]),
) -> FeedbackOut:
content = content.strip()
contact = contact.strip()
if not content:
raise HTTPException(status_code=400, detail="反馈内容不能为空")
if len(content) > _CONTENT_MAX:
raise HTTPException(status_code=400, detail="反馈内容过长")
if not contact:
raise HTTPException(status_code=400, detail="联系方式不能为空")
if len(contact) > _CONTACT_MAX:
raise HTTPException(status_code=400, detail="联系方式过长")
files = [f for f in (images or []) if f is not None and f.filename]
if len(files) > _MAX_IMAGES:
raise HTTPException(status_code=400, detail=f"最多上传 {_MAX_IMAGES} 张图片")
urls: list[str] = []
for f in files:
data = await f.read()
try:
urls.append(media.save_feedback_image(user.id, data))
except media.MediaError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
fb = feedback_repo.create_feedback(
db, user_id=user.id, content=content, contact=contact, images=urls,
)
logger.info("feedback id=%d user_id=%d images=%d", fb.id, user.id, len(urls))
return FeedbackOut.model_validate(fb)
+153 -52
View File
@@ -5,12 +5,22 @@
from __future__ import annotations
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
import time
from concurrent.futures import ThreadPoolExecutor
from threading import Lock
from fastapi import APIRouter, HTTPException
from app.core.config import settings
from app.integrations.meituan import MeituanCpsError, get_referral_link, query_coupon
from app.core.ranking import (
dedup,
filter_items,
get_distance_km,
inject_billboard,
merge_category_pages,
shuffle_pages,
sort_by_sales,
split_pages,
)
from app.integrations.meituan import MeituanCpsError, _call as mt_call, get_referral_link, query_coupon
from app.schemas.meituan import (
CouponCard,
CouponListRequest,
@@ -25,11 +35,16 @@ logger = logging.getLogger("shagua.meituan")
router = APIRouter(prefix="/api/v1/meituan", tags=["meituan-cps"])
_MAX_RECALL_PAGES = 5
_MAX_DISTANCE_KM = 8.0
_FEED_CACHE_TTL = 300
@router.post("/coupons", response_model=CouponListResponse, summary="券列表(为您推荐)")
_feed_cache: dict[str, tuple[float, list[list[dict]]]] = {}
_feed_lock = Lock()
@router.post("/coupons", response_model=CouponListResponse, summary="券列表(通用查询)")
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)
try:
raw = query_coupon(
@@ -56,65 +71,151 @@ def list_coupons(req: CouponListRequest) -> CouponListResponse:
)
_TOPIC_ROUNDS = [
(3, 3), # 爆款筛选
(2, 2), # 今日必推
(1, 5), # 精选 + 限时筛选
]
def _interleave(waimai: list[dict], daodian: list[dict]) -> list[CouponCard]:
items: list[CouponCard] = []
seen: set[str] = set()
i = j = 0
while i < len(waimai) or j < len(daodian):
for _ in range(2):
if i < len(waimai):
card = CouponCard.from_raw(waimai[i]); i += 1
if card.product_view_sign not in seen:
seen.add(card.product_view_sign); items.append(card)
if j < len(daodian):
card = CouponCard.from_raw(daodian[j]); j += 1
if card.product_view_sign not in seen:
seen.add(card.product_view_sign); items.append(card)
return items
# ────────────────────── Feed 排序策略 (MVP) ──────────────────────
@router.post("/feed", response_model=FeedResponse, summary="混合feed(外卖+到店交叉, 无限流)")
def _lbs_recall(
keyword: str,
lon: float,
lat: float,
is_daodian: bool,
) -> list[dict]:
"""searchText + sortField=6 翻页召回,收集 ≤8km 商品,遇到整页都 >8km 或翻满 10 页停止。"""
lon_i = int(lon * 1_000_000)
lat_i = int(lat * 1_000_000)
all_items: list[dict] = []
search_id: str | None = None
for _ in range(_MAX_RECALL_PAGES):
body: dict = {
"longitude": lon_i,
"latitude": lat_i,
"searchText": keyword,
"sortField": 6,
"pageSize": 20,
}
if search_id:
body["searchId"] = search_id
try:
data = mt_call("/cps_open/common/api/v1/query_coupon", body)
except MeituanCpsError:
break
items = data.get("data") or []
search_id = data.get("searchId")
has_next = data.get("hasNext", False)
if not items:
break
page_has_valid = False
for item in items:
dist = get_distance_km(item, is_daodian)
if dist is not None and dist <= _MAX_DISTANCE_KM:
all_items.append(item)
page_has_valid = True
if not page_has_valid or not has_next:
break
return dedup(all_items)
def _fetch_billboard(
platform: int,
topic_id: int,
lon: float,
lat: float,
) -> list[dict]:
"""listTopiId 拉榜单,固定 20 条,不做距离过滤。"""
try:
data = query_coupon(
longitude=lon,
latitude=lat,
platform=platform,
list_topic_id=topic_id,
)
return data.get("data") or []
except MeituanCpsError:
return []
def _build_feed(lon: float, lat: float) -> list[list[dict]]:
"""完整 pipeline:并发召回 → 过滤 → 销量排序 → 分页 → shuffle → 榜单加成 → 合并。"""
with ThreadPoolExecutor(max_workers=4) as pool:
f_wm = pool.submit(_lbs_recall, "外卖", lon, lat, False)
f_dd = pool.submit(_lbs_recall, "到店餐饮", lon, lat, True)
f_wm_bill = pool.submit(_fetch_billboard, 1, 1, lon, lat)
f_dd_bill = pool.submit(_fetch_billboard, 2, 3, lon, lat)
wm_raw = f_wm.result()
dd_raw = f_dd.result()
wm_billboard = f_wm_bill.result()
dd_billboard = f_dd_bill.result()
logger.info(
"[feed:build] waimai=%d daodian=%d wm_bill=%d dd_bill=%d",
len(wm_raw), len(dd_raw), len(wm_billboard), len(dd_billboard),
)
wm_filtered = filter_items(wm_raw, is_daodian=False, max_km=_MAX_DISTANCE_KM)
dd_filtered = filter_items(dd_raw, is_daodian=True, max_km=_MAX_DISTANCE_KM)
wm_sorted = sort_by_sales(wm_filtered)
dd_sorted = sort_by_sales(dd_filtered)
wm_pages = shuffle_pages(split_pages(wm_sorted))
dd_pages = shuffle_pages(split_pages(dd_sorted))
wm_pages = inject_billboard(wm_pages, wm_billboard, per_page=4, max_inject_pages=5)
dd_pages = inject_billboard(dd_pages, dd_billboard, per_page=4, max_inject_pages=5)
return merge_category_pages(wm_pages, dd_pages)
def _cache_key(lon: float, lat: float) -> str:
return f"{lon:.4f},{lat:.4f}"
def _get_or_build_feed(lon: float, lat: float) -> list[list[dict]]:
key = _cache_key(lon, lat)
now = time.time()
with _feed_lock:
entry = _feed_cache.get(key)
if entry and now - entry[0] < _FEED_CACHE_TTL:
return entry[1]
pages = _build_feed(lon, lat)
with _feed_lock:
_feed_cache[key] = (time.time(), pages)
expired = [k for k, (t, _) in _feed_cache.items() if now - t > _FEED_CACHE_TTL]
for k in expired:
del _feed_cache[k]
return pages
@router.post("/feed", response_model=FeedResponse, summary="首页推荐 feed (MVP 排序策略)")
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
lon, lat = req.longitude, req.latitude
page_idx = req.page - 1
logger.info("[feed] page=%s lon=%.6f lat=%.6f", req.page, lon, lat)
def _fetch_topic(platform: int, biz_line: int | None, topic: int) -> list[dict]:
try:
return (query_coupon(
longitude=lon, latitude=lat,
platform=platform, biz_line=biz_line,
list_topic_id=topic, page_size=20,
).get("data") or [])
except MeituanCpsError:
return []
pages = _get_or_build_feed(lon, lat)
if page_idx >= len(_TOPIC_ROUNDS):
if page_idx >= len(pages):
return FeedResponse(items=[], has_next=False, page=req.page)
wm_topic, dd_topic = _TOPIC_ROUNDS[page_idx]
with ThreadPoolExecutor(max_workers=2) as pool:
f_wm = pool.submit(_fetch_topic, 1, None, wm_topic)
f_dd = pool.submit(_fetch_topic, 2, 1, dd_topic)
waimai, daodian = f_wm.result(), f_dd.result()
items = _interleave(waimai, daodian)
has_next = page_idx + 1 < len(_TOPIC_ROUNDS)
items = [CouponCard.from_raw(it) for it in pages[page_idx]]
has_next = page_idx + 1 < len(pages)
return FeedResponse(items=items, has_next=has_next, page=req.page)
@router.post("/referral-link", response_model=ReferralLinkResponse, summary="换取推广链接(点抢时调)")
def referral_link(req: ReferralLinkRequest) -> ReferralLinkResponse:
if not settings.mt_cps_configured:
return ReferralLinkResponse(link="", link_map={})
try:
raw = get_referral_link(
product_view_sign=req.product_view_sign,
-59
View File
@@ -1,59 +0,0 @@
"""用户资料 endpoint。
路由前缀 `/api/v1/user`,全部需 Bearer 鉴权(用户级数据,不同于 MVP 透传接口):
PATCH /profile 修改昵称
POST /avatar 上传头像(multipart,字段名 file)
DELETE / 注销账号(软删除 + 匿名化)
昵称/头像后端持久化到 user 表,登录后经 /auth/me 与登录响应回传客户端——这才是
"改完重登仍生效"的唯一数据源(此前客户端只存本地、退登即丢)。
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, File, HTTPException, UploadFile
from app.api.deps import CurrentUser, DbSession
from app.core import media
from app.repositories import user as user_repo
from app.schemas.auth import UserOut
from app.schemas.user import OkResponse, ProfileUpdateRequest
logger = logging.getLogger("shagua.user")
router = APIRouter(prefix="/api/v1/user", tags=["user"])
@router.patch("/profile", response_model=UserOut, summary="修改昵称")
def update_profile(req: ProfileUpdateRequest, user: CurrentUser, db: DbSession) -> UserOut:
updated = user_repo.update_nickname(db, user, nickname=req.nickname)
logger.info("update nickname user_id=%d", user.id)
return UserOut.model_validate(updated)
@router.post("/avatar", response_model=UserOut, summary="上传头像")
async def upload_avatar(
user: CurrentUser,
db: DbSession,
file: UploadFile = File(..., description="头像图片(jpeg/png/webp,≤5MB)"),
) -> UserOut:
data = await file.read()
try:
url = media.save_avatar(user.id, data)
except media.MediaError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
old_url = user.avatar_url
updated = user_repo.set_avatar_url(db, user, avatar_url=url)
media.delete_avatar(old_url) # 落库成功后再删旧文件,避免删了新的没存上
logger.info("update avatar user_id=%d url=%s", user.id, url)
return UserOut.model_validate(updated)
@router.delete("", response_model=OkResponse, summary="注销账号(软删除)")
def delete_account(user: CurrentUser, db: DbSession) -> OkResponse:
media.delete_avatar(user.avatar_url)
user_repo.soft_delete_account(db, user)
logger.info("delete account user_id=%d", user.id)
return OkResponse()
-14
View File
@@ -53,18 +53,11 @@ class Settings(BaseSettings):
SMS_SEND_INTERVAL_SEC: int = 60
# ===== 美团联盟 CPS =====
# 未配置时所有 /api/v1/meituan/* 接口 200 返空(优雅降级),不影响登录/领券等其他业务。
MT_CPS_APP_KEY: str = ""
MT_CPS_APP_SECRET: str = ""
MT_CPS_HOST: str = "https://media.meituan.com"
MT_CPS_TIMEOUT_SEC: int = 15
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 同时用于
# 微信登录(code 换 openid)与转账,必须与 App 端开放平台 appid 一致。
@@ -113,13 +106,6 @@ class Settings(BaseSettings):
# price/step 每帧也是 LLM,可能 >30s;对齐客户端 agent ApiClient 的 60s 读超时。
PRICEBOT_COMPARE_TIMEOUT_SEC: int = 60
# ===== 媒体文件(用户头像上传)=====
# 落盘根目录(data/ 已 gitignore,上传不进库);对外经 StaticFiles 挂在 MEDIA_URL_PREFIX。
# 生产可改由 nginx 直接 serve MEDIA_ROOT,绕过应用进程。
MEDIA_ROOT: str = "./data/media"
MEDIA_URL_PREFIX: str = "/media"
AVATAR_MAX_BYTES: int = 5 * 1024 * 1024 # 头像最大 5MB
# ===== CORS =====
CORS_ALLOW_ORIGINS: str = ""
-76
View File
@@ -1,76 +0,0 @@
"""用户上传文件(头像 / 反馈截图)的本地落盘 + 对外 URL 构建。
落盘到 `settings.MEDIA_ROOT/<子目录>/`,对外以 `settings.MEDIA_URL_PREFIX` 暴露
(main.py 用 StaticFiles 挂载)。返回**相对路径** URL(如 `/media/avatars/xxx.jpg`),
由客户端按自己的 BASE_URL 解析为绝对地址——服务端不知道客户端怎么访问到自己(dev 下
可能是 10.0.2.2 / LAN IP),所以不返回绝对 URL。
安全:不信任客户端给的 content-type / 文件名,改用魔数嗅探判定真实图片类型;
文件名服务端随机生成,杜绝路径穿越与覆盖。
"""
from __future__ import annotations
import secrets
from pathlib import Path
from app.core.config import settings
class MediaError(Exception):
"""上传文件不合法(类型/大小)。调用方转 400。"""
def _media_dir(subdir: str) -> Path:
d = Path(settings.MEDIA_ROOT) / subdir
d.mkdir(parents=True, exist_ok=True)
return d
def _sniff_ext(data: bytes) -> str | None:
"""按魔数判定图片类型,返回扩展名;非支持类型返回 None。"""
if data[:3] == b"\xff\xd8\xff":
return ".jpg"
if data[:8] == b"\x89PNG\r\n\x1a\n":
return ".png"
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return ".webp"
return None
def _save_image(subdir: str, user_id: int, data: bytes) -> str:
"""通用图片落盘:校验非空 / ≤上限 / 魔数为图片,随机文件名,返回相对 URL。"""
if not data:
raise MediaError("空文件")
if len(data) > settings.AVATAR_MAX_BYTES:
raise MediaError("图片过大(上限 5MB)")
ext = _sniff_ext(data)
if ext is None:
raise MediaError("仅支持 JPEG / PNG / WebP 图片")
fname = f"u{user_id}_{secrets.token_hex(8)}{ext}"
(_media_dir(subdir) / fname).write_bytes(data)
return f"{settings.MEDIA_URL_PREFIX}/{subdir}/{fname}"
def save_avatar(user_id: int, data: bytes) -> str:
"""保存头像,返回相对 URL(`/media/avatars/<file>`)。"""
return _save_image("avatars", user_id, data)
def save_feedback_image(user_id: int, data: bytes) -> str:
"""保存反馈截图,返回相对 URL(`/media/feedback/<file>`)。"""
return _save_image("feedback", user_id, data)
def delete_avatar(url: str | None) -> None:
"""删除本服务托管的旧头像文件;外部 URL(如微信头像)或空值不处理。"""
prefix = f"{settings.MEDIA_URL_PREFIX}/avatars/"
if not url or not url.startswith(prefix):
return
fname = url[len(prefix):]
if not fname or "/" in fname or "\\" in fname or ".." in fname:
return # 防路径穿越
try:
(_media_dir("avatars") / fname).unlink(missing_ok=True)
except OSError:
pass
+143
View File
@@ -0,0 +1,143 @@
"""首页 Feed 排序策略 (MVP 版)
LBS 召回 → 距离过滤 → 销量重排 → 分页 shuffle → 榜单加成 → 双品类同页交错。
"""
from __future__ import annotations
import random
import re
from typing import Any
def parse_sale_volume(text: str | None) -> int:
"""'热销8.5万+' → 85000, '热销1k+' → 1000, None → 0"""
if not text:
return 0
m = re.search(r"([\d.]+)\s*(万|k)?", text, re.IGNORECASE)
if not m:
return 0
num = float(m.group(1))
unit = (m.group(2) or "").lower()
if unit == "":
num *= 10000
elif unit == "k":
num *= 1000
return int(num)
def get_distance_km(item: dict[str, Any], is_daodian: bool) -> float | None:
"""提取距离(km)。外卖单位千米,到店单位米需÷1000。>50km 视为脏数据返回 None。"""
raw = (item.get("deliverablePoiInfo") or {}).get("deliveryDistance")
if raw is None:
return None
try:
d = float(raw)
except (ValueError, TypeError):
return None
if is_daodian:
d /= 1000
if d > 50:
return None
return d
def get_sell_price(item: dict[str, Any]) -> float | None:
raw = (item.get("couponPackDetail") or {}).get("sellPrice")
if raw is None:
return None
try:
p = float(raw)
return p if p > 0 else None
except (ValueError, TypeError):
return None
def get_product_sign(item: dict[str, Any]) -> str:
cpd = item.get("couponPackDetail") or {}
return cpd.get("productViewSign") or cpd.get("skuViewId") or ""
def filter_items(
items: list[dict], is_daodian: bool, max_km: float = 8.0,
) -> list[dict]:
"""距离 ≤ max_km、售价 > 0、去脏数据。"""
result = []
for item in items:
if get_sell_price(item) is None:
continue
dist = get_distance_km(item, is_daodian)
if dist is None or dist > max_km:
continue
result.append(item)
return result
def dedup(items: list[dict]) -> list[dict]:
seen: set[str] = set()
result = []
for item in items:
sign = get_product_sign(item)
if sign and sign not in seen:
seen.add(sign)
result.append(item)
return result
def sort_by_sales(items: list[dict]) -> list[dict]:
def _key(item: dict) -> int:
vol = (item.get("couponPackDetail") or {}).get("saleVolume")
return parse_sale_volume(vol)
return sorted(items, key=_key, reverse=True)
def split_pages(items: list[dict], page_size: int = 20) -> list[list[dict]]:
if not items:
return []
return [items[i : i + page_size] for i in range(0, len(items), page_size)]
def shuffle_pages(pages: list[list[dict]]) -> list[list[dict]]:
"""每页内 Fisher-Yates shuffle,不跨页。"""
result = []
for page in pages:
shuffled = page[:]
random.shuffle(shuffled)
result.append(shuffled)
return result
def inject_billboard(
pages: list[list[dict]],
billboard: list[dict],
per_page: int = 4,
max_inject_pages: int = 5,
) -> list[list[dict]]:
"""将榜单商品分配到前 N 页,每页额外加 per_page 个,随机位置插入。"""
existing = {get_product_sign(it) for p in pages for it in p}
unique = [it for it in billboard if get_product_sign(it) not in existing]
idx = 0
for i in range(min(max_inject_pages, len(pages))):
batch = unique[idx : idx + per_page]
idx += per_page
for it in batch:
pages[i].insert(random.randint(0, len(pages[i])), it)
return pages
def merge_category_pages(
waimai_pages: list[list[dict]],
daodian_pages: list[list[dict]],
) -> list[list[dict]]:
"""同页合并 + shuffle,页数取两者最大值。"""
n = max(len(waimai_pages), len(daodian_pages))
result = []
for k in range(n):
merged: list[dict] = []
if k < len(waimai_pages):
merged.extend(waimai_pages[k])
if k < len(daodian_pages):
merged.extend(daodian_pages[k])
random.shuffle(merged)
result.append(merged)
return result
+8 -14
View File
@@ -27,25 +27,19 @@ def _ensure_sqlite_dir(url: str) -> None:
_ensure_sqlite_dir(settings.DATABASE_URL)
_is_sqlite = settings.DATABASE_URL.startswith("sqlite")
# SQLite 跨线程访问要 check_same_thread=False;PG/MySQL 不需要这个参数
_connect_args: dict = {}
if _is_sqlite:
if settings.DATABASE_URL.startswith("sqlite"):
_connect_args["check_same_thread"] = False
_engine_kwargs: dict = {
"connect_args": _connect_args,
engine = create_engine(
settings.DATABASE_URL,
connect_args=_connect_args,
# echo 在 dev 下打 SQL,生产关掉
"echo": settings.APP_DEBUG and not settings.is_prod,
"future": 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)
echo=settings.APP_DEBUG and not settings.is_prod,
future=True,
pool_pre_ping=True,
)
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False, expire_on_commit=False)
-16
View File
@@ -8,22 +8,17 @@ import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.api.v1.ad import router as ad_router
from app.api.v1.auth import router as auth_router
from app.api.v1.compare import router as compare_router
from app.api.v1.coupon import router as coupon_router
from app.api.v1.feedback import router as feedback_router
from app.api.v1.meituan import router as meituan_router
from app.api.v1.savings import router as savings_router
from app.api.v1.signin import router as signin_router
from app.api.v1.tasks import router as tasks_router
from app.api.v1.user import router as user_router
from app.api.v1.wallet import router as wallet_router
from app.core.config import settings
from app.core.logging import setup_logging
@@ -70,8 +65,6 @@ def health() -> dict[str, str]:
app.include_router(auth_router)
app.include_router(user_router)
app.include_router(feedback_router)
app.include_router(coupon_router)
app.include_router(compare_router)
app.include_router(meituan_router)
@@ -80,12 +73,3 @@ app.include_router(signin_router)
app.include_router(tasks_router)
app.include_router(savings_router)
app.include_router(ad_router)
# 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。
_media_root = Path(settings.MEDIA_ROOT)
_media_root.mkdir(parents=True, exist_ok=True)
app.mount(
settings.MEDIA_URL_PREFIX,
StaticFiles(directory=str(_media_root)),
name="media",
)
-1
View File
@@ -1,6 +1,5 @@
"""所有 ORM model 必须在这里 import 一次,Alembic / metadata 才能扫到。"""
from app.models.ad_reward import AdRewardRecord # noqa: F401
from app.models.feedback import Feedback # noqa: F401
from app.models.savings import SavingsRecord # noqa: F401
from app.models.signin import SigninRecord # noqa: F401
from app.models.task import UserTask # noqa: F401
-35
View File
@@ -1,35 +0,0 @@
"""用户反馈表(帮助与反馈)。
每条 = 用户一次提交。content 必填,contact 必填(微信/QQ/手机,便于回访),images 为可选的
截图 URL 列表(/media/feedback/...,JSON 存)。status: new(待处理)/ handled(已处理)。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class Feedback(Base):
__tablename__ = "feedback"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False
)
content: Mapped[str] = mapped_column(Text, nullable=False)
contact: Mapped[str] = mapped_column(String(128), nullable=False)
# 截图 URL 列表(相对路径,如 ["/media/feedback/u1_ab12.jpg"]);无图为 None
images: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
# new(待处理) / handled(已处理)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="new")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<Feedback id={self.id} user_id={self.user_id} status={self.status}>"
+3 -4
View File
@@ -8,8 +8,7 @@ from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
@@ -29,8 +28,8 @@ class SavingsRecord(Base):
title: 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 道菜」展开。
dishes: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
# 菜品名列表(JSON),前 2 道直接展示,其余收进「还有 N 道菜」展开。SQLite 存为 TEXT。
dishes: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
# 来源:demo(演示) / compare(真实比价上报)
source: Mapped[str] = mapped_column(String(16), nullable=False, default="compare")
-27
View File
@@ -1,27 +0,0 @@
"""feedback 表写入。"""
from __future__ import annotations
from sqlalchemy.orm import Session
from app.models.feedback import Feedback
def create_feedback(
db: Session,
*,
user_id: int,
content: str,
contact: str,
images: list[str] | None,
) -> Feedback:
fb = Feedback(
user_id=user_id,
content=content,
contact=contact,
images=images or None,
status="new",
)
db.add(fb)
db.commit()
db.refresh(fb)
return fb
-28
View File
@@ -46,31 +46,3 @@ def upsert_user_for_login(
db.commit()
db.refresh(user)
return user
def update_nickname(db: Session, user: User, *, nickname: str) -> User:
user.nickname = nickname
db.commit()
db.refresh(user)
return user
def set_avatar_url(db: Session, user: User, *, avatar_url: str) -> User:
user.avatar_url = avatar_url
db.commit()
db.refresh(user)
return user
def soft_delete_account(db: Session, user: User) -> None:
"""注销账号:软删除 + 匿名化。
把 phone 改成 `deleted_<id>` 释放唯一约束,允许同号码重新注册成全新账号;
清空 PII(昵称/头像),保留行用于审计。status=deleted 后将无法再登录该行
(登录接口校验 status==active)。
"""
user.status = "deleted"
user.phone = f"deleted_{user.id}"
user.nickname = None
user.avatar_url = None
db.commit()
-14
View File
@@ -1,14 +0,0 @@
"""反馈相关响应 schema。请求是 multipart 表单(content/contact/images),在路由里直接校验。"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class FeedbackOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
status: str
created_at: datetime
+38 -3
View File
@@ -1,11 +1,46 @@
"""美团 CPS 券列表 / 换链相关 schemas。"""
from __future__ import annotations
import re
from typing import Any
from pydantic import BaseModel, Field
def _format_price_label(price_lbl: dict[str, Any]) -> str | None:
history = price_lbl.get("historyPriceLabel")
if history:
return history
beat = price_lbl.get("beatMTLabel")
if not beat:
return None
m = re.match(r"比日常团购省([\d.]+)元", beat)
if m:
return f"比团购省 {m.group(1)}"
return beat
def _format_rank_label(raw: str | None) -> str | None:
if not raw:
return None
m = re.search(r"(外卖|美食|饮品|轻食|奶茶|咖啡|火锅|烧烤|甜品|快餐).*?第(\d+)名", raw)
if m:
return f"{m.group(1)}榜第 {m.group(2)}"
m2 = re.search(r"第(\d+)名", raw)
if m2:
return f"销量榜第 {m2.group(1)}"
return raw
def _format_rating_label(raw: str | None) -> str | None:
if not raw:
return None
m = re.search(r"([\d.]+)\s*分", raw)
if m:
return f"点评 {m.group(1)}"
return raw
# ───────────────── 券卡片(归一化后给客户端) ─────────────────
class CouponCard(BaseModel):
@@ -102,9 +137,9 @@ class CouponCard(BaseModel):
available_poi_num=avail.get("availablePoiNum"),
coupon_num=cpd.get("couponNum"),
valid_days=valid_info.get("couponValidDay"),
price_label=price_lbl.get("historyPriceLabel") or price_lbl.get("beatMTLabel"),
rank_label=label.get("productRankLabel"),
rating_label=label.get("dianPingRankLabel"),
price_label=_format_price_label(price_lbl),
rank_label=_format_rank_label(label.get("productRankLabel")),
rating_label=_format_rating_label(label.get("dianPingRankLabel")),
)
-20
View File
@@ -1,20 +0,0 @@
"""用户资料(昵称/头像)相关请求 schemas。响应复用 [auth.UserOut]。"""
from __future__ import annotations
from pydantic import BaseModel, Field, field_validator
class ProfileUpdateRequest(BaseModel):
nickname: str = Field(..., min_length=1, max_length=16, description="昵称,1-16 字")
@field_validator("nickname")
@classmethod
def _strip_non_blank(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("昵称不能为空")
return v
class OkResponse(BaseModel):
ok: bool = True
+8 -25
View File
@@ -15,37 +15,20 @@ alembic upgrade head
---
## 1. 首次初始化(clone 后)
### 1.1 用 PostgreSQL(推荐,与生产一致)
前置:本机已装 PostgreSQL 16 + 知道 postgres 超级用户密码。
```bash
# (1) 装依赖(在你的虚拟环境里, 含 psycopg)
# (1) 装依赖(在你的虚拟环境里)
pip install -e .
# (2) 一键建用户 + 建库 + 写 .env + 跑迁移
python scripts/init_postgres.py
# 按提示输入 PG 超级用户密码即可。脚本会:
# - 自动建业务用户 shaguabijia_app + 业务库 shaguabijia
# - 自动生成业务用户强密码,写入 .env 的 DATABASE_URL
# - 跑完 alembic upgrade head
# (2) 配 .env(从模板复制,至少填 JWT_SECRET_KEY)
cp .env.example .env
# (3) 启动
./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
# (3) SQLite 默认库需要 data/ 目录存在
mkdir -p data
# (4) 跑迁移建表 —— 关键
alembic upgrade head
# (5) 启动(run.sh 会自动重跑 3+4,所以平时直接 ./run.sh 也行)
./run.sh
```
> 也可以直接 `bash scripts/migrate.sh` 只做迁移、不启服务(部署/CI 用)。
-3
View File
@@ -17,9 +17,6 @@ dependencies = [
"sqlalchemy>=2.0.35",
"alembic>=1.13.3",
# PostgreSQL 驱动 (psycopg3, SQLAlchemy 2.0 时代默认, 不要再装 psycopg2)
"psycopg[binary]>=3.1",
# JWT 签名 / 校验
"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())