Compare commits

...

3 Commits

Author SHA1 Message Date
chenshuobo c1ce109f13 feat: 新增 GET /api/v1/user/records 订单记录分页接口
- 新增 OrderRecord 模型 (user_id, platform, store_name, dishes, order_date, price, original_price, savings)
- 新增 order_record schema (OrderRecordOut + OrderListResponse)
- 新增 CRUD: get_records_page 按 order_date DESC 分页查询
- 新增 /api/v1/user/records 路由, 需 Bearer token 鉴权, 支持 page/size 参数
- Alembic 迁移: add order_record table
- platform 支持: meituan_waimai / taobao_shanguang / jd_waimai / meituan / taobao / jd
- dishes 字段 DB 存 JSON 字符串, 接口返回 list[str]

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:11:05 +08:00
chenshuobo bd126635e3 fix: 放宽 Python 版本要求至 >=3.10
代码已使用 from __future__ import annotations, 兼容 3.10 运行时

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:36:36 +08:00
chenshuobo 8fc291a30c feat: 接入美团联盟 CPS 优惠券推荐模块
- 新增 app/core/meituan.py: 美团联盟 API 客户端 (S-Ca 签名, query_coupon, get_referral_link)
- 新增 app/api/v1/meituan.py: /api/v1/meituan/coupons 和 /feed 路由, 支持按经纬度推荐
- 新增 app/schemas/meituan.py: 请求/响应 Pydantic Schema
- app/main.py: 注册 meituan_router
- app/core/config.py: 新增 MT_CPS_* 配置项, .env 路径改为绝对路径避免 CWD 问题
- .env.example: 补充美团联盟 CPS 配置模板

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:36:36 +08:00
13 changed files with 613 additions and 2 deletions
+7
View File
@@ -34,6 +34,13 @@ SMS_MOCK=true
SMS_CODE_TTL_SEC=300
SMS_SEND_INTERVAL_SEC=60
# ===== 美团联盟 CPS =====
# 美团联盟后台 → 媒体管理 拿到的 appkey 和密钥
MT_CPS_APP_KEY=
MT_CPS_APP_SECRET=
# 默认渠道追踪标识(sid),用于区分不同 app 的 CPS 数据
MT_CPS_DEFAULT_SID=sgbjia
# ===== CORS =====
# 逗号分隔,生产留空(只让 app 调,不开放 web)。本地开发可加 http://localhost:5173 之类
CORS_ALLOW_ORIGINS=
@@ -0,0 +1,49 @@
"""add order_record table
Revision ID: c64a944a6139
Revises: 9c559acc164a
Create Date: 2026-05-26 21:09:40.047619
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'c64a944a6139'
down_revision: Union[str, Sequence[str], None] = '9c559acc164a'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('order_record',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('platform', sa.String(length=30), nullable=False),
sa.Column('store_name', sa.String(length=128), nullable=False),
sa.Column('dishes', sa.Text(), nullable=False),
sa.Column('order_date', sa.String(length=10), nullable=False),
sa.Column('price', sa.String(length=20), nullable=False),
sa.Column('original_price', sa.String(length=20), nullable=True),
sa.Column('savings', sa.String(length=20), nullable=True),
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('order_record', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_order_record_user_id'), ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('order_record', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_order_record_user_id'))
op.drop_table('order_record')
# ### end Alembic commands ###
+125
View File
@@ -0,0 +1,125 @@
"""美团 CPS 券列表 + 换链。
不需要登录,客户端传经纬度即可。
"""
from __future__ import annotations
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from fastapi import APIRouter, HTTPException
from app.core.meituan import MeituanCpsError, get_referral_link, query_coupon
from app.schemas.meituan import (
CouponCard,
CouponListRequest,
CouponListResponse,
FeedRequest,
FeedResponse,
ReferralLinkRequest,
ReferralLinkResponse,
)
logger = logging.getLogger("shagua.meituan")
router = APIRouter(prefix="/api/v1/meituan", tags=["meituan-cps"])
@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:
raw = query_coupon(
longitude=req.longitude,
latitude=req.latitude,
platform=req.platform,
biz_line=req.biz_line,
list_topic_id=req.list_topic_id,
search_text=req.keyword,
search_id=req.search_id,
sort_field=req.sort_field,
page_no=req.page,
page_size=req.page_size,
)
except MeituanCpsError as e:
logger.error("query_coupon failed: %s", e)
raise HTTPException(status_code=502, detail=f"meituan: {e}") from e
items = [CouponCard.from_raw(it) for it in (raw.get("data") or [])]
return CouponListResponse(
items=items,
has_next=raw.get("hasNext", False),
search_id=raw.get("searchId"),
)
_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
@router.post("/feed", response_model=FeedResponse, summary="混合feed(外卖+到店交叉, 无限流)")
def feed(req: FeedRequest) -> FeedResponse:
page_idx = req.page - 1
lon, lat = req.longitude, req.latitude
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 []
if page_idx >= len(_TOPIC_ROUNDS):
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)
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:
try:
raw = get_referral_link(
product_view_sign=req.product_view_sign,
platform=req.platform,
biz_line=req.biz_line,
sid=req.sid,
link_type_list=req.link_type_list,
)
except MeituanCpsError as e:
logger.error("get_referral_link failed: %s", e)
raise HTTPException(status_code=502, detail=f"meituan: {e}") from e
link_map = raw.get("referralLinkMap") or {}
link = raw.get("data") or link_map.get("1") or link_map.get("3") or next(iter(link_map.values()), "")
return ReferralLinkResponse(link=link, link_map=link_map)
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Query
from app.api.deps import CurrentUser, DbSession
from app.crud import order_record as crud_order
from app.schemas.order_record import OrderListResponse, OrderRecordOut
logger = logging.getLogger("shagua.api.user")
router = APIRouter(prefix="/api/v1/user", tags=["user"])
@router.get("/records", response_model=OrderListResponse, summary="分页查询当前用户的订单记录")
def list_records(
user: CurrentUser,
db: DbSession,
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
) -> OrderListResponse:
rows, has_next = crud_order.get_records_page(db, user_id=user.id, page=page, size=size)
records = [
OrderRecordOut(
id=r.id,
platform=r.platform,
store_name=r.store_name,
dishes=json.loads(r.dishes) if r.dishes else [],
order_date=r.order_date,
price=r.price,
original_price=r.original_price,
savings=r.savings,
)
for r in rows
]
return OrderListResponse(records=records, has_next=has_next, page=page)
+11 -1
View File
@@ -6,15 +6,18 @@
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from typing import Literal
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file=str(_PROJECT_ROOT / ".env"),
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
@@ -46,6 +49,13 @@ class Settings(BaseSettings):
SMS_CODE_TTL_SEC: int = 300
SMS_SEND_INTERVAL_SEC: int = 60
# ===== 美团联盟 CPS =====
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"
# ===== CORS =====
CORS_ALLOW_ORIGINS: str = ""
+140
View File
@@ -0,0 +1,140 @@
"""美团联盟 CPS 开放接口客户端。
签名方式:类阿里云网关 S-Ca 头,但 stringToSign **只有 4 段**——
METHOD\n + Content-MD5\n + Headers + Url
没有 Accept / Content-Type / Date 行(加了就签名失败)。
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import logging
import time
from typing import Any
import httpx
from app.core.config import settings
logger = logging.getLogger("shagua.meituan")
class MeituanCpsError(Exception):
"""美团 CPS 接口调用失败。"""
def _content_md5(body: bytes) -> str:
return base64.b64encode(hashlib.md5(body).digest()).decode()
def _sign(secret: str, method: str, content_md5: str, path: str,
signed_headers: dict[str, str]) -> str:
block = "".join(
f"{k}:{signed_headers[k]}\n"
for k in sorted(signed_headers)
)
sts = f"{method}\n{content_md5}\n{block}{path}"
return base64.b64encode(
hmac.new(secret.encode(), sts.encode(), hashlib.sha256).digest()
).decode()
def _call(path: str, body_obj: dict[str, Any]) -> dict[str, Any]:
if not settings.MT_CPS_APP_KEY or not settings.MT_CPS_APP_SECRET:
raise MeituanCpsError("MT_CPS_APP_KEY / MT_CPS_APP_SECRET not configured")
body = json.dumps(body_obj, ensure_ascii=False).encode("utf-8")
content_md5 = _content_md5(body)
ts = str(int(time.time() * 1000))
signed_headers = {"S-Ca-App": settings.MT_CPS_APP_KEY, "S-Ca-Timestamp": ts}
signature = _sign(settings.MT_CPS_APP_SECRET, "POST", content_md5, path, signed_headers)
headers = {
"Content-Type": "application/json",
"Content-MD5": content_md5,
"S-Ca-App": settings.MT_CPS_APP_KEY,
"S-Ca-Timestamp": ts,
"S-Ca-Signature-Headers": "S-Ca-Timestamp,S-Ca-App",
"S-Ca-Signature": signature,
}
url = f"{settings.MT_CPS_HOST}{path}"
try:
resp = httpx.post(url, content=body, headers=headers, timeout=settings.MT_CPS_TIMEOUT_SEC)
except httpx.HTTPError as e:
logger.exception("[MT] http error calling %s", url)
raise MeituanCpsError(f"meituan http error: {e}") from e
if resp.status_code != 200:
logger.error("[MT] HTTP %s body=%s", resp.status_code, resp.text[:500])
raise MeituanCpsError(f"meituan http {resp.status_code}")
data = resp.json()
if data.get("code") != 0:
logger.error("[MT] api error code=%s message=%s", data.get("code"), data.get("message"))
raise MeituanCpsError(f"code={data.get('code')} {data.get('message')}")
return data
# ────────────────────── 业务方法 ──────────────────────
def query_coupon(
*,
longitude: float,
latitude: float,
platform: int = 1,
biz_line: int | None = None,
list_topic_id: int | None = 3,
search_text: str | None = None,
search_id: str | None = None,
sort_field: int | None = None,
page_no: int = 1,
page_size: int = 20,
) -> dict[str, Any]:
body: dict[str, Any] = {
"platform": platform,
"longitude": int(longitude * 1_000_000),
"latitude": int(latitude * 1_000_000),
"pageNo": page_no,
"pageSize": page_size,
}
if biz_line is not None:
body["bizLine"] = biz_line
if search_text:
body["searchText"] = search_text
if sort_field is None:
sort_field = 6
elif list_topic_id is not None:
body["listTopiId"] = list_topic_id
if sort_field is not None:
body["sortField"] = sort_field
if search_id:
body["searchId"] = search_id
return _call("/cps_open/common/api/v1/query_coupon", body)
def get_referral_link(
*,
product_view_sign: str,
platform: int = 1,
biz_line: int | None = None,
sid: str | None = None,
link_type_list: list[int] | None = None,
) -> dict[str, Any]:
body: dict[str, Any] = {"productViewSign": product_view_sign}
if platform == 2:
body["platform"] = 2
if biz_line is not None:
body["bizLine"] = biz_line
body["sid"] = sid or settings.MT_CPS_DEFAULT_SID
body["linkTypeList"] = link_type_list or [1, 3]
return _call("/cps_open/common/api/v1/get_referral_link", body)
+26
View File
@@ -0,0 +1,26 @@
from __future__ import annotations
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.order_record import OrderRecord
def get_records_page(
db: Session,
*,
user_id: int,
page: int = 1,
size: int = 20,
) -> tuple[list[OrderRecord], bool]:
offset = (page - 1) * size
stmt = (
select(OrderRecord)
.where(OrderRecord.user_id == user_id)
.order_by(OrderRecord.order_date.desc(), OrderRecord.id.desc())
.offset(offset)
.limit(size + 1)
)
rows = list(db.execute(stmt).scalars().all())
has_next = len(rows) > size
return rows[:size], has_next
+4
View File
@@ -12,6 +12,8 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.v1.auth import router as auth_router
from app.api.v1.meituan import router as meituan_router
from app.api.v1.user import router as user_router
from app.core.config import settings
from app.core.logging import setup_logging
@@ -57,3 +59,5 @@ def health() -> dict[str, str]:
app.include_router(auth_router)
app.include_router(meituan_router)
app.include_router(user_router)
+1
View File
@@ -1,2 +1,3 @@
"""所有 ORM model 必须在这里 import 一次,Alembic / metadata 才能扫到。"""
from app.models.user import User # noqa: F401
from app.models.order_record import OrderRecord # noqa: F401
+32
View File
@@ -0,0 +1,32 @@
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, Numeric, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
class OrderRecord(Base):
__tablename__ = "order_record"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("user.id"), nullable=False, index=True)
platform: Mapped[str] = mapped_column(String(30), nullable=False)
store_name: Mapped[str] = mapped_column(String(128), nullable=False)
dishes: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
order_date: Mapped[str] = mapped_column(String(10), nullable=False)
price: Mapped[str] = mapped_column(String(20), nullable=False)
original_price: Mapped[str | None] = mapped_column(String(20), nullable=True)
savings: Mapped[str | None] = mapped_column(String(20), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
+157
View File
@@ -0,0 +1,157 @@
"""美团 CPS 券列表 / 换链相关 schemas。"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
# ───────────────── 券卡片(归一化后给客户端) ─────────────────
class CouponCard(BaseModel):
product_view_sign: str
platform: int = Field(..., description="1=外卖/到家, 2=到店")
biz_line: int | None = Field(None, description="到店子类: 1到餐 2到综 3酒店 4门票")
name: str
head_image_url: str
brand_name: str | None = None
brand_logo_url: str | None = None
sell_price: str
original_price: str
discount_amount: str = Field(..., description="优惠金额 = 原价 - 现价")
commission_rate: str = Field(..., description="佣金比例, 如 1.4%")
commission_amount: str | None = Field(None, description="预估佣金元")
sale_volume: str | None = None
poi_name: str | None = Field(None, description="最近门店名")
distance_text: str | None = Field(None, description="格式化距离, 如 600m / 1.5km")
distance_meters: float | None = Field(None, description="原始距离(米)")
available_poi_num: int | None = Field(None, description="可用门店数")
coupon_num: int | None = Field(None, description="券张数(外卖兑换券)")
valid_days: int | None = Field(None, description="券有效天数")
price_label: str | None = Field(None, description="如 15天低价")
rank_label: str | None = Field(None, description="如 2小时北京外卖销量榜第1名")
rating_label: str | None = Field(None, description="如 4.6分")
@classmethod
def from_raw(cls, item: dict[str, Any]) -> CouponCard:
cpd = item.get("couponPackDetail") or {}
brand = item.get("brandInfo") or {}
comm = item.get("commissionInfo") or {}
poi = item.get("deliverablePoiInfo") or {}
avail = item.get("availablePoiInfo") or {}
label = cpd.get("productLabel") or {}
price_lbl = label.get("pricePowerLabel") or {}
valid_info = item.get("couponValidTimeInfo") or {}
platform = cpd.get("platform") or 1
biz_line = cpd.get("bizLine")
head_url = cpd.get("headUrl") or ""
if "@" in head_url:
head_url = head_url.split("@")[0]
sell = cpd.get("sellPrice") or "0"
orig = cpd.get("originalPrice") or "0"
try:
discount = f"{float(orig) - float(sell):.2f}"
except (ValueError, TypeError):
discount = "0"
cp = comm.get("commissionPercent") or "0"
try:
rate = f"{float(cp) / 100:.1f}%"
except (ValueError, TypeError):
rate = "0%"
raw_dist = poi.get("deliveryDistance")
dist_meters: float | None = None
dist_text: str | None = None
if raw_dist not in (None, "null", ""):
try:
d = float(raw_dist)
dist_meters = d * 1000 if platform == 1 else d
if dist_meters < 1000:
dist_text = f"{int(dist_meters)}m"
else:
dist_text = f"{dist_meters / 1000:.1f}km"
except (ValueError, TypeError):
pass
return cls(
product_view_sign=cpd.get("productViewSign") or cpd.get("skuViewId") or "",
platform=platform,
biz_line=biz_line,
name=cpd.get("name") or "",
head_image_url=head_url,
brand_name=brand.get("brandName"),
brand_logo_url=brand.get("brandLogoUrl"),
sell_price=sell,
original_price=orig,
discount_amount=discount,
commission_rate=rate,
commission_amount=comm.get("commission"),
sale_volume=cpd.get("saleVolume"),
poi_name=poi.get("poiName"),
distance_text=dist_text,
distance_meters=dist_meters,
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"),
)
# ───────────────── 券列表 请求 / 响应 ─────────────────
class CouponListRequest(BaseModel):
longitude: float = Field(..., description="经度, 如 116.404")
latitude: float = Field(..., description="纬度, 如 39.928")
platform: int = Field(1, description="1=外卖/到家, 2=到店")
biz_line: int | None = Field(None, description="到店子类: 1到餐 2到综 3酒店 4门票; 外卖不填")
list_topic_id: int | None = Field(3, description="榜单: 1精选 2今日必推 3爆款筛选 5限时筛选")
keyword: str | None = Field(None, description="搜索关键词(填了会忽略 list_topic_id)")
sort_field: int | None = Field(None, description="1价格 2销量 6离我最近; 搜索默认6")
search_id: str | None = Field(None, description="翻页token, 首页不填")
page: int = Field(1, ge=1)
page_size: int = Field(20, ge=1, le=20)
class CouponListResponse(BaseModel):
items: list[CouponCard]
has_next: bool = False
search_id: str | None = None
class FeedRequest(BaseModel):
longitude: float = Field(..., description="经度")
latitude: float = Field(..., description="纬度")
page: int = Field(1, ge=1)
page_size: int = Field(20, ge=1, le=20)
class FeedResponse(BaseModel):
items: list[CouponCard]
has_next: bool = False
page: int = 1
# ───────────────── 换链 请求 / 响应 ─────────────────
class ReferralLinkRequest(BaseModel):
product_view_sign: str = Field(..., min_length=1)
platform: int = Field(1, description="1=外卖/到家, 2=到店")
biz_line: int | None = None
sid: str | None = Field(None, description="渠道追踪标识, 不填用默认")
link_type_list: list[int] | None = Field(None, description="1=H5长链 2=H5短链 3=deeplink; 不填默认[1,3]")
class ReferralLinkResponse(BaseModel):
link: str = Field(..., description="默认推广链接")
link_map: dict[str, str] = Field(default_factory=dict, description="各类型链接 {linkType: url}")
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
from pydantic import BaseModel, ConfigDict
class OrderRecordOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
platform: str
store_name: str
dishes: list[str]
order_date: str
price: str
original_price: str | None = None
savings: str | None = None
class OrderListResponse(BaseModel):
records: list[OrderRecordOut]
has_next: bool = False
page: int = 1
+1 -1
View File
@@ -2,7 +2,7 @@
name = "shaguabijia-app-server"
version = "0.1.0"
description = "Shaguabijia 正式 App 后端 (FastAPI + SQLAlchemy + JWT)"
requires-python = ">=3.11"
requires-python = ">=3.10"
dependencies = [
# Web 框架 & ASGI
"fastapi>=0.115.0",