Compare commits

..

2 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
陈世睿 49379fd045 feat(user): 用户资料与反馈接口(昵称/头像/注销 + 帮助反馈)
- /api/v1/user: PATCH 改昵称、POST 上传头像、DELETE 注销账号(软删除+匿名化)
- /api/v1/feedback: 提交反馈(内容/联系方式/截图入库)
- 新增 media 模块(图片落盘+魔数校验)+ /media 静态服务
- feedback 表 + Alembic 迁移(部署需 alembic upgrade head)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 22:27:36 +08:00
16 changed files with 458 additions and 341 deletions
+44
View File
@@ -0,0 +1,44 @@
"""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')
+20 -7
View File
@@ -1,17 +1,20 @@
"""外卖比价业务透传端点。
"""比价业务透传端点(外卖 + 电商两套)
把客户端的 POST /api/v1/intent/recognize 和 /api/v1/price/step 请求原样转发给
pricebot-backend 的 /api/intent/recognize 和 /api/price/step。MVP 阶段**不鉴权**
(同 coupon/step,见 docs/待办与技术债.md P1:device_id 透传区分设备,待补 JWT +
device_id↔user_id 绑定后才能做用户级画像)。
把客户端的 POST /api/v1/{intent/recognize, price/step, ecom/intent/recognize, ecom/step}
请求原样转发给 pricebot-backend 的对应路径。MVP 阶段**不鉴权**(同 coupon/step,
见 docs/待办与技术债.md P1:device_id 透传区分设备,待补 JWT + device_id↔user_id
绑定后才能做用户级画像)。
外卖(food):
- Phase 1 /intent/recognize:从源平台(淘宝闪购/美团/京东外卖)购物车页识别店名+菜品+
价格,一次性。
- Phase 2 /price/step:多轮循环,在目标平台复现订单读到手价,直到 done。
电商(ecom):
- Phase 1 /ecom/intent/recognize:从源平台(淘宝/抖音/拼多多)SKU+详情页识别商品,一次性。
- Phase 2 /ecom/step:多轮循环,在目标平台搜索商品并采价,直到 done。
真正的目标驱动比价逻辑在 pricebot-backend(GoalEngine,另一个 repo),本接口只是透传壳。
电商(ecom)那两个端点 food MVP 暂不需要,以后放开 scene 时再加 /ecom/intent/recognize
和 /ecom/step 两行即可。
pricebot 协议文档:
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)")
async def price_step(request: Request) -> dict[str, Any]:
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")
+63
View File
@@ -0,0 +1,63 @@
"""帮助与反馈 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)
+45 -153
View File
@@ -5,22 +5,11 @@
from __future__ import annotations
import logging
import time
from concurrent.futures import ThreadPoolExecutor
from threading import Lock
from concurrent.futures import ThreadPoolExecutor, as_completed
from fastapi import APIRouter, HTTPException
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.integrations.meituan import MeituanCpsError, get_referral_link, query_coupon
from app.schemas.meituan import (
CouponCard,
CouponListRequest,
@@ -35,15 +24,8 @@ 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
_feed_cache: dict[str, tuple[float, list[list[dict]]]] = {}
_feed_lock = Lock()
@router.post("/coupons", response_model=CouponListResponse, summary="券列表(通用查询)")
@router.post("/coupons", response_model=CouponListResponse, summary="券列表(为您推荐)")
def list_coupons(req: CouponListRequest) -> CouponListResponse:
logger.info("[coupons] lon=%.6f lat=%.6f topic=%s", req.longitude, req.latitude, req.list_topic_id)
try:
@@ -71,146 +53,56 @@ def list_coupons(req: CouponListRequest) -> CouponListResponse:
)
# ────────────────────── Feed 排序策略 (MVP) ──────────────────────
_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
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 排序策略)")
@router.post("/feed", response_model=FeedResponse, summary="混合feed(外卖+到店交叉, 无限流)")
def feed(req: FeedRequest) -> FeedResponse:
lon, lat = req.longitude, req.latitude
page_idx = req.page - 1
lon, lat = req.longitude, req.latitude
logger.info("[feed] page=%s lon=%.6f lat=%.6f", req.page, lon, lat)
pages = _get_or_build_feed(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 []
if page_idx >= len(pages):
if page_idx >= len(_TOPIC_ROUNDS):
return FeedResponse(items=[], has_next=False, page=req.page)
items = [CouponCard.from_raw(it) for it in pages[page_idx]]
has_next = page_idx + 1 < len(pages)
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)
return FeedResponse(items=items, has_next=has_next, page=req.page)
+59
View File
@@ -0,0 +1,59 @@
"""用户资料 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()
+7
View File
@@ -106,6 +106,13 @@ 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
@@ -0,0 +1,76 @@
"""用户上传文件(头像 / 反馈截图)的本地落盘 + 对外 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
@@ -1,143 +0,0 @@
"""首页 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
+16
View File
@@ -8,17 +8,22 @@ 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
@@ -65,6 +70,8 @@ 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)
@@ -73,3 +80,12 @@ 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,5 +1,6 @@
"""所有 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
@@ -0,0 +1,35 @@
"""用户反馈表(帮助与反馈)。
每条 = 用户一次提交。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}>"
+27
View File
@@ -0,0 +1,27 @@
"""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,3 +46,31 @@ 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
@@ -0,0 +1,14 @@
"""反馈相关响应 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
+3 -38
View File
@@ -1,46 +1,11 @@
"""美团 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):
@@ -137,9 +102,9 @@ class CouponCard(BaseModel):
available_poi_num=avail.get("availablePoiNum"),
coupon_num=cpd.get("couponNum"),
valid_days=valid_info.get("couponValidDay"),
price_label=_format_price_label(price_lbl),
rank_label=_format_rank_label(label.get("productRankLabel")),
rating_label=_format_rating_label(label.get("dianPingRankLabel")),
price_label=price_lbl.get("historyPriceLabel") or price_lbl.get("beatMTLabel"),
rank_label=label.get("productRankLabel"),
rating_label=label.get("dianPingRankLabel"),
)
+20
View File
@@ -0,0 +1,20 @@
"""用户资料(昵称/头像)相关请求 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