Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b995ee8ada |
@@ -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"
|
|
||||||
)
|
|
||||||
@@ -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')
|
|
||||||
@@ -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
@@ -5,12 +5,22 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
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 fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.ranking import (
|
||||||
from app.integrations.meituan import MeituanCpsError, get_referral_link, query_coupon
|
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 (
|
from app.schemas.meituan import (
|
||||||
CouponCard,
|
CouponCard,
|
||||||
CouponListRequest,
|
CouponListRequest,
|
||||||
@@ -25,11 +35,16 @@ logger = logging.getLogger("shagua.meituan")
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/meituan", tags=["meituan-cps"])
|
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:
|
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(
|
||||||
@@ -56,65 +71,151 @@ def list_coupons(req: CouponListRequest) -> CouponListResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
_TOPIC_ROUNDS = [
|
# ────────────────────── Feed 排序策略 (MVP) ──────────────────────
|
||||||
(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
|
|
||||||
|
|
||||||
|
|
||||||
@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:
|
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
|
lon, lat = req.longitude, req.latitude
|
||||||
|
page_idx = req.page - 1
|
||||||
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)
|
||||||
|
|
||||||
def _fetch_topic(platform: int, biz_line: int | None, topic: int) -> list[dict]:
|
pages = _get_or_build_feed(lon, lat)
|
||||||
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(_TOPIC_ROUNDS):
|
if page_idx >= len(pages):
|
||||||
return FeedResponse(items=[], has_next=False, page=req.page)
|
return FeedResponse(items=[], has_next=False, page=req.page)
|
||||||
|
|
||||||
wm_topic, dd_topic = _TOPIC_ROUNDS[page_idx]
|
items = [CouponCard.from_raw(it) for it in pages[page_idx]]
|
||||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
has_next = page_idx + 1 < len(pages)
|
||||||
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)
|
return FeedResponse(items=items, has_next=has_next, page=req.page)
|
||||||
|
|
||||||
|
|
||||||
@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,
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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 一致。
|
||||||
@@ -113,13 +106,6 @@ class Settings(BaseSettings):
|
|||||||
# price/step 每帧也是 LLM,可能 >30s;对齐客户端 agent ApiClient 的 60s 读超时。
|
# price/step 每帧也是 LLM,可能 >30s;对齐客户端 agent ApiClient 的 60s 读超时。
|
||||||
PRICEBOT_COMPARE_TIMEOUT_SEC: int = 60
|
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 =====
|
||||||
CORS_ALLOW_ORIGINS: str = ""
|
CORS_ALLOW_ORIGINS: str = ""
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
-16
@@ -8,22 +8,17 @@ import logging
|
|||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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.ad import router as ad_router
|
||||||
from app.api.v1.auth import router as auth_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.compare import router as compare_router
|
||||||
from app.api.v1.coupon import router as coupon_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.meituan import router as meituan_router
|
||||||
from app.api.v1.savings import router as savings_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.signin import router as signin_router
|
||||||
from app.api.v1.tasks import router as tasks_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.api.v1.wallet import router as wallet_router
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.logging import setup_logging
|
from app.core.logging import setup_logging
|
||||||
@@ -70,8 +65,6 @@ def health() -> dict[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(user_router)
|
|
||||||
app.include_router(feedback_router)
|
|
||||||
app.include_router(coupon_router)
|
app.include_router(coupon_router)
|
||||||
app.include_router(compare_router)
|
app.include_router(compare_router)
|
||||||
app.include_router(meituan_router)
|
app.include_router(meituan_router)
|
||||||
@@ -80,12 +73,3 @@ app.include_router(signin_router)
|
|||||||
app.include_router(tasks_router)
|
app.include_router(tasks_router)
|
||||||
app.include_router(savings_router)
|
app.include_router(savings_router)
|
||||||
app.include_router(ad_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,6 +1,5 @@
|
|||||||
"""所有 ORM model 必须在这里 import 一次,Alembic / metadata 才能扫到。"""
|
"""所有 ORM model 必须在这里 import 一次,Alembic / metadata 才能扫到。"""
|
||||||
from app.models.ad_reward import AdRewardRecord # noqa: F401
|
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.savings import SavingsRecord # noqa: F401
|
||||||
from app.models.signin import SigninRecord # noqa: F401
|
from app.models.signin import SigninRecord # noqa: F401
|
||||||
from app.models.task import UserTask # noqa: F401
|
from app.models.task import UserTask # noqa: F401
|
||||||
|
|||||||
@@ -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}>"
|
|
||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -46,31 +46,3 @@ def upsert_user_for_login(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(user)
|
db.refresh(user)
|
||||||
return 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()
|
|
||||||
|
|||||||
@@ -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
@@ -1,11 +1,46 @@
|
|||||||
"""美团 CPS 券列表 / 换链相关 schemas。"""
|
"""美团 CPS 券列表 / 换链相关 schemas。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
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):
|
class CouponCard(BaseModel):
|
||||||
@@ -102,9 +137,9 @@ class CouponCard(BaseModel):
|
|||||||
available_poi_num=avail.get("availablePoiNum"),
|
available_poi_num=avail.get("availablePoiNum"),
|
||||||
coupon_num=cpd.get("couponNum"),
|
coupon_num=cpd.get("couponNum"),
|
||||||
valid_days=valid_info.get("couponValidDay"),
|
valid_days=valid_info.get("couponValidDay"),
|
||||||
price_label=price_lbl.get("historyPriceLabel") or price_lbl.get("beatMTLabel"),
|
price_label=_format_price_label(price_lbl),
|
||||||
rank_label=label.get("productRankLabel"),
|
rank_label=_format_rank_label(label.get("productRankLabel")),
|
||||||
rating_label=label.get("dianPingRankLabel"),
|
rating_label=_format_rating_label(label.get("dianPingRankLabel")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 用)。
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
|
||||||
Reference in New Issue
Block a user