Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee6c2e4710 | |||
| 6c7eaa9734 | |||
| c3c64fa06d | |||
| e69e244de7 | |||
| 2ebde935f9 | |||
| b3d3fda744 | |||
| b76e5bd515 | |||
| 56f4548654 | |||
| 4d444b7c43 | |||
| abd8eabf9b |
@@ -0,0 +1,26 @@
|
||||
"""merge feedback + savings jsonb heads
|
||||
|
||||
Revision ID: a7afc22c14b4
|
||||
Revises: ef96beb47b1e, d1e2f3a4b5c6
|
||||
Create Date: 2026-05-30 10:40:10.703958
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a7afc22c14b4'
|
||||
down_revision: Union[str, Sequence[str], None] = ('ef96beb47b1e', 'd1e2f3a4b5c6')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,36 @@
|
||||
"""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"
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""merge pg_jsonb and feedback heads
|
||||
|
||||
Revision ID: f01db5d77dac
|
||||
Revises: ef96beb47b1e, d1e2f3a4b5c6
|
||||
Create Date: 2026-05-29 16:09:58.498935
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'f01db5d77dac'
|
||||
down_revision: Union[str, Sequence[str], None] = ('ef96beb47b1e', 'd1e2f3a4b5c6')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,42 @@
|
||||
"""order record table (归因订单)
|
||||
|
||||
Revision ID: order_record_table
|
||||
Revises: f01db5d77dac
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "order_record_table"
|
||||
down_revision = "f01db5d77dac"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"order_record",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("user.id"), nullable=False, index=True),
|
||||
sa.Column("client_event_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("platform", sa.String(length=32), nullable=False),
|
||||
sa.Column("platform_package", sa.String(length=128), nullable=True),
|
||||
sa.Column("pay_channel", sa.String(length=16), nullable=False),
|
||||
sa.Column("compared_price_cents", sa.Integer(), nullable=False),
|
||||
sa.Column("paid_amount_cents", sa.Integer(), nullable=False),
|
||||
sa.Column("device_id", sa.String(length=128), nullable=True),
|
||||
sa.Column("source", sa.String(length=32), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.UniqueConstraint("user_id", "client_event_id", name="uq_order_user_event"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("order_record")
|
||||
@@ -0,0 +1,49 @@
|
||||
"""savings_record:真实比价上报字段 + (user_id, client_event_id) 幂等唯一约束
|
||||
|
||||
把 savings_record 升级为「记账唯一真相表」:真实上报(source='compare')写入
|
||||
原价/比价价/支付渠道/平台包名/源平台名/源链接/幂等键/device_id;
|
||||
demo seeder 行(source='demo')这些列均为 NULL。
|
||||
|
||||
Revision ID: savings_report_fields
|
||||
Revises: order_record_table
|
||||
Create Date: 2026-05-31 19:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'savings_report_fields'
|
||||
down_revision: Union[str, Sequence[str], None] = 'order_record_table'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('savings_record', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('original_price_cents', sa.Integer(), nullable=True))
|
||||
batch_op.add_column(sa.Column('compared_price_cents', sa.Integer(), nullable=True))
|
||||
batch_op.add_column(sa.Column('pay_channel', sa.String(length=16), nullable=True))
|
||||
batch_op.add_column(sa.Column('platform_package', sa.String(length=128), nullable=True))
|
||||
batch_op.add_column(sa.Column('source_platform_name', sa.String(length=32), nullable=True))
|
||||
batch_op.add_column(sa.Column('source_deeplink', sa.String(length=512), nullable=True))
|
||||
batch_op.add_column(sa.Column('client_event_id', sa.String(length=64), nullable=True))
|
||||
batch_op.add_column(sa.Column('device_id', sa.String(length=128), nullable=True))
|
||||
# (user_id, client_event_id) 幂等;demo 行 client_event_id 为 NULL 不冲突
|
||||
batch_op.create_unique_constraint('uq_savings_user_event', ['user_id', 'client_event_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('savings_record', schema=None) as batch_op:
|
||||
batch_op.drop_constraint('uq_savings_user_event', type_='unique')
|
||||
batch_op.drop_column('device_id')
|
||||
batch_op.drop_column('client_event_id')
|
||||
batch_op.drop_column('source_deeplink')
|
||||
batch_op.drop_column('source_platform_name')
|
||||
batch_op.drop_column('platform_package')
|
||||
batch_op.drop_column('pay_channel')
|
||||
batch_op.drop_column('compared_price_cents')
|
||||
batch_op.drop_column('original_price_cents')
|
||||
+6
-2
@@ -84,12 +84,14 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
|
||||
|
||||
@router.get("/reward-status", response_model=AdRewardStatusOut, summary="今日看广告发奖进度")
|
||||
def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut:
|
||||
used, limit, coin_per = crud_ad.today_status(db, user.id)
|
||||
used, limit, coin_per, round_count, cooldown_until = crud_ad.today_status(db, user.id)
|
||||
return AdRewardStatusOut(
|
||||
used_today=used,
|
||||
daily_limit=limit,
|
||||
remaining=max(0, limit - used),
|
||||
coin_per_ad=coin_per,
|
||||
round_count=round_count,
|
||||
cooldown_until=cooldown_until,
|
||||
)
|
||||
|
||||
|
||||
@@ -118,7 +120,7 @@ def test_grant(user: CurrentUser, db: DbSession) -> TestGrantOut:
|
||||
except crud_ad.UnknownUserError as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e
|
||||
|
||||
used, limit, coin_per = crud_ad.today_status(db, user.id)
|
||||
used, limit, coin_per, round_count, cooldown_until = crud_ad.today_status(db, user.id)
|
||||
logger.info("ad TEST grant user_id=%d status=%s coin=%d", user.id, rec.status, rec.coin)
|
||||
return TestGrantOut(
|
||||
granted=(rec.status == "granted"),
|
||||
@@ -128,4 +130,6 @@ def test_grant(user: CurrentUser, db: DbSession) -> TestGrantOut:
|
||||
daily_limit=limit,
|
||||
remaining=max(0, limit - used),
|
||||
coin_per_ad=coin_per,
|
||||
round_count=round_count,
|
||||
cooldown_until=cooldown_until,
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app.core.config import settings
|
||||
from app.integrations.meituan import MeituanCpsError, get_referral_link, query_coupon
|
||||
from app.schemas.meituan import (
|
||||
CouponCard,
|
||||
@@ -27,6 +28,8 @@ router = APIRouter(prefix="/api/v1/meituan", tags=["meituan-cps"])
|
||||
|
||||
@router.post("/coupons", response_model=CouponListResponse, summary="券列表(为您推荐)")
|
||||
def list_coupons(req: CouponListRequest) -> CouponListResponse:
|
||||
if not settings.mt_cps_configured:
|
||||
return CouponListResponse(items=[], has_next=False, search_id=None)
|
||||
logger.info("[coupons] lon=%.6f lat=%.6f topic=%s", req.longitude, req.latitude, req.list_topic_id)
|
||||
try:
|
||||
raw = query_coupon(
|
||||
@@ -78,6 +81,8 @@ def _interleave(waimai: list[dict], daodian: list[dict]) -> list[CouponCard]:
|
||||
|
||||
@router.post("/feed", response_model=FeedResponse, summary="混合feed(外卖+到店交叉, 无限流)")
|
||||
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
|
||||
logger.info("[feed] page=%s lon=%.6f lat=%.6f", req.page, lon, lat)
|
||||
@@ -108,6 +113,8 @@ def feed(req: FeedRequest) -> FeedResponse:
|
||||
|
||||
@router.post("/referral-link", response_model=ReferralLinkResponse, summary="换取推广链接(点抢时调)")
|
||||
def referral_link(req: ReferralLinkRequest) -> ReferralLinkResponse:
|
||||
if not settings.mt_cps_configured:
|
||||
return ReferralLinkResponse(link="", link_map={})
|
||||
try:
|
||||
raw = get_referral_link(
|
||||
product_view_sign=req.product_view_sign,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.repositories import order as crud_order
|
||||
from app.repositories import savings as crud_savings
|
||||
from app.schemas.order import (
|
||||
OrderListItem,
|
||||
OrderListOut,
|
||||
OrderReportOut,
|
||||
OrderReportRequest,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/order", tags=["order"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/report",
|
||||
response_model=OrderReportOut,
|
||||
summary="上报归因订单(比价后5分钟内点链接 + 支付金额与比价价相差≤1元)",
|
||||
)
|
||||
def report_order(req: OrderReportRequest, user: CurrentUser, db: DbSession) -> OrderReportOut:
|
||||
# 记账唯一真相表是 savings_record(source='compare');order_record 已降级,不再写入。
|
||||
rec, duplicated = crud_savings.create_from_report(db, user.id, req)
|
||||
return OrderReportOut(
|
||||
id=rec.id,
|
||||
platform=rec.platform or req.platform,
|
||||
pay_channel=rec.pay_channel or req.pay_channel,
|
||||
compared_price_cents=rec.compared_price_cents or 0,
|
||||
paid_amount_cents=rec.order_amount_cents,
|
||||
duplicated=duplicated,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/list",
|
||||
response_model=OrderListOut,
|
||||
summary="当前用户的归因订单明细(按时间倒序)",
|
||||
)
|
||||
def list_orders(user: CurrentUser, db: DbSession, limit: int = 100) -> OrderListOut:
|
||||
rows, total = crud_order.list_orders(db, user.id, limit=limit)
|
||||
items = [
|
||||
OrderListItem(
|
||||
id=r.id,
|
||||
platform=r.platform,
|
||||
pay_channel=r.pay_channel,
|
||||
compared_price_cents=r.compared_price_cents,
|
||||
paid_amount_cents=r.paid_amount_cents,
|
||||
saved_cents=max(0, r.compared_price_cents - r.paid_amount_cents),
|
||||
created_at=r.created_at,
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
return OrderListOut(items=items, total=total)
|
||||
@@ -53,11 +53,18 @@ class Settings(BaseSettings):
|
||||
SMS_SEND_INTERVAL_SEC: int = 60
|
||||
|
||||
# ===== 美团联盟 CPS =====
|
||||
# 未配置时所有 /api/v1/meituan/* 接口 200 返空(优雅降级),不影响登录/领券等其他业务。
|
||||
MT_CPS_APP_KEY: str = ""
|
||||
MT_CPS_APP_SECRET: str = ""
|
||||
MT_CPS_HOST: str = "https://media.meituan.com"
|
||||
MT_CPS_TIMEOUT_SEC: int = 15
|
||||
MT_CPS_DEFAULT_SID: str = "sgbjia"
|
||||
|
||||
@property
|
||||
def mt_cps_configured(self) -> bool:
|
||||
"""美团 CPS 凭证齐全(缺则接口返空,而非 502)。"""
|
||||
return bool(self.MT_CPS_APP_KEY and self.MT_CPS_APP_SECRET)
|
||||
|
||||
# ===== 微信支付(商家转账到零钱 / 提现)=====
|
||||
# 真实凭证放 .env(已 gitignore),证书 .pem 放 secrets/。WECHAT_APP_ID 同时用于
|
||||
# 微信登录(code 换 openid)与转账,必须与 App 端开放平台 appid 一致。
|
||||
|
||||
+11
-5
@@ -59,16 +59,22 @@ TASK_REWARDS: dict[str, int] = {
|
||||
|
||||
|
||||
# ===== 看激励视频发金币(穿山甲 S2S 服务端回调发奖)=====
|
||||
# 看完一个激励视频发的金币(≈¥0.01,汇率 10000 金币=1 元)。
|
||||
# 作用:① 回调缺/坏 reward_amount 时的回退值;② 客户端进度接口展示的"单次预告金币"。
|
||||
# 看完一个激励视频发的金币(100 金币 ≈¥0.01,汇率 10000 金币=1 元)。
|
||||
# 作用:① 回调缺/坏 reward_amount 时的回退值;② 客户端进度接口展示的"单次预告金币";
|
||||
# ③ test-grant 本地联调的发奖额。
|
||||
# 真实发放以穿山甲回调带回的 reward_amount 为准(见 resolve_ad_reward_coin),后台应把
|
||||
# 代码位"奖励数量"配成与本值一致(如 100),保证"广告内展示 / 进度预告 / 实际到账"三者一致。
|
||||
# 代码位"奖励数量"配成与本值一致(=100),保证"广告内展示 / 进度预告 / 实际到账"三者一致。
|
||||
AD_REWARD_COIN: int = 100
|
||||
# 单次发奖金币上限:夹紧穿山甲回调里异常的 reward_amount(如后台多打一个 0),防刷爆余额。
|
||||
MAX_AD_REWARD_COIN: int = 1000
|
||||
# 每用户每日发奖次数上限,防刷 + 控成本。
|
||||
# ⚠️ 占位值:正式上线前按产品策略 + 广告 eCPM 实测收益重新核定,改这里即可。
|
||||
DAILY_AD_REWARD_LIMIT: int = 10
|
||||
# ⚠️ 测试阶段值=20:正式上线前按产品策略 + 广告 eCPM 实测收益重新核定,改这里即可。
|
||||
DAILY_AD_REWARD_LIMIT: int = 20
|
||||
# 一轮看 N 次激励视频,看完一轮强制 10 分钟冷却(reward-status 派生 cooldown_until 给客户端,
|
||||
# 客户端任务行 CTA 在冷却期间显示"MM:SS"且不可点)。冷却是 UX 约束,后端发奖只看 daily_limit,
|
||||
# 冷却期间穿山甲回调仍照常发奖,不另设拒发分支。
|
||||
VIDEO_ROUND_REQUIRED_COUNT: int = 3
|
||||
VIDEO_ROUND_COOLDOWN_SECONDS: int = 600
|
||||
|
||||
|
||||
def resolve_ad_reward_coin(reward_amount: str | int | None) -> int:
|
||||
|
||||
+14
-8
@@ -27,19 +27,25 @@ def _ensure_sqlite_dir(url: str) -> None:
|
||||
|
||||
_ensure_sqlite_dir(settings.DATABASE_URL)
|
||||
|
||||
_is_sqlite = settings.DATABASE_URL.startswith("sqlite")
|
||||
|
||||
# SQLite 跨线程访问要 check_same_thread=False;PG/MySQL 不需要这个参数
|
||||
_connect_args: dict = {}
|
||||
if settings.DATABASE_URL.startswith("sqlite"):
|
||||
if _is_sqlite:
|
||||
_connect_args["check_same_thread"] = False
|
||||
|
||||
engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
connect_args=_connect_args,
|
||||
_engine_kwargs: dict = {
|
||||
"connect_args": _connect_args,
|
||||
# echo 在 dev 下打 SQL,生产关掉
|
||||
echo=settings.APP_DEBUG and not settings.is_prod,
|
||||
future=True,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
"echo": settings.APP_DEBUG and not settings.is_prod,
|
||||
"future": True,
|
||||
"pool_pre_ping": True,
|
||||
}
|
||||
# SQLite 用单文件不需要池;PG/MySQL 必须显式池化 + recycle 防 idle 断连
|
||||
if not _is_sqlite:
|
||||
_engine_kwargs.update(pool_size=10, max_overflow=20, pool_recycle=3600)
|
||||
|
||||
engine = create_engine(settings.DATABASE_URL, **_engine_kwargs)
|
||||
|
||||
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False, expire_on_commit=False)
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ 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.order import router as order_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
|
||||
@@ -80,6 +81,7 @@ app.include_router(signin_router)
|
||||
app.include_router(tasks_router)
|
||||
app.include_router(savings_router)
|
||||
app.include_router(ad_router)
|
||||
app.include_router(order_router)
|
||||
|
||||
# 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。
|
||||
_media_root = Path(settings.MEDIA_ROOT)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""所有 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.order import OrderRecord # 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
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class OrderRecord(Base):
|
||||
"""归因订单记录。
|
||||
|
||||
客户端判定「比价完 5 分钟内 + 用户点了结果链接 + 支付金额与比价价相差 ≤1 元」成立时,
|
||||
带 JWT 上报一条,视为用户经由我们下了一笔单。本表只做记录(不发金币,避免刷单风险)。
|
||||
|
||||
幂等:(user_id, client_event_id) 唯一。client_event_id 由客户端为每次归因生成 UUID,
|
||||
重试/重复上报不会产生多条。
|
||||
"""
|
||||
|
||||
__tablename__ = "order_record"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "client_event_id", name="uq_order_user_event"),
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
client_event_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
# 平台展示名(如 美团 / 淘宝闪购),来自比价结果 comparison_results.platform_name
|
||||
platform: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
platform_package: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# 支付渠道:wechat / alipay(哪条支付通道检测到的支付成功)
|
||||
pay_channel: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
# 我们给出的(用户点击的那个平台的)比价价,单位分
|
||||
compared_price_cents: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
# 实际支付金额,单位分
|
||||
paid_amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
device_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
source: Mapped[str] = mapped_column(String(32), nullable=False, default="attribution")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
+28
-3
@@ -8,7 +8,8 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
@@ -16,6 +17,11 @@ from app.db.base import Base
|
||||
|
||||
class SavingsRecord(Base):
|
||||
__tablename__ = "savings_record"
|
||||
__table_args__ = (
|
||||
# 真实上报(source='compare')按 (user, client_event_id) 幂等;
|
||||
# demo 行 client_event_id 为 NULL,不参与唯一性冲突(SQLite/PG 均允许多 NULL)
|
||||
UniqueConstraint("user_id", "client_event_id", name="uq_savings_user_event"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
@@ -28,11 +34,30 @@ class SavingsRecord(Base):
|
||||
title: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# 店铺名(订单明细卡标题,如「窑鸡王(王府井店)」)
|
||||
shop_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# 菜品名列表(JSON),前 2 道直接展示,其余收进「还有 N 道菜」展开。SQLite 存为 TEXT。
|
||||
dishes: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
|
||||
# 菜品名列表:PG 用 JSONB(可建 GIN 索引),SQLite 用 JSON(TEXT)。前 2 道直接展示,其余收进「还有 N 道菜」。
|
||||
dishes: Mapped[list[str]] = mapped_column(
|
||||
JSON().with_variant(JSONB(), "postgresql"), nullable=False, default=list
|
||||
)
|
||||
# 来源:demo(演示) / compare(真实比价上报)
|
||||
source: Mapped[str] = mapped_column(String(16), nullable=False, default="compare")
|
||||
|
||||
# ===== 真实比价上报(source='compare')新增字段;demo 行这些均为 NULL =====
|
||||
# 源平台原价(用户原本要付的钱,分);省额 saved_amount_cents = original − order_amount(实付)
|
||||
original_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# 我们当时给出的比价价(分),审计/备用展示
|
||||
compared_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# 支付渠道:wechat / alipay
|
||||
pay_channel: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
# 实际下单平台包名
|
||||
platform_package: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# 源平台展示名(如「美团」),用于「原价 ¥36.8(美团)」展示
|
||||
source_platform_name: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
# 源平台重进链接(预留:后续「重新进店/重复下单」用,本期只存不展示)
|
||||
source_deeplink: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
# 客户端幂等键(UUID);demo 行为 NULL
|
||||
client_event_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
device_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
|
||||
@@ -10,11 +10,19 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.rewards import AD_REWARD_COIN, DAILY_AD_REWARD_LIMIT, cn_today
|
||||
from app.core.rewards import (
|
||||
AD_REWARD_COIN,
|
||||
DAILY_AD_REWARD_LIMIT,
|
||||
VIDEO_ROUND_COOLDOWN_SECONDS,
|
||||
VIDEO_ROUND_REQUIRED_COUNT,
|
||||
cn_today,
|
||||
)
|
||||
from app.repositories import wallet as crud_wallet
|
||||
from app.models.ad_reward import AdRewardRecord
|
||||
from app.models.user import User
|
||||
@@ -102,7 +110,49 @@ def _commit_record(db: Session, rec: AdRewardRecord, trans_id: str) -> AdRewardR
|
||||
return rec
|
||||
|
||||
|
||||
def today_status(db: Session, user_id: int) -> tuple[int, int, int]:
|
||||
"""客户端查"今日看广告发奖"进度:返回 (今日已发次数, 每日上限, 单次金币)。"""
|
||||
used = _granted_today(db, user_id, cn_today().isoformat())
|
||||
return used, DAILY_AD_REWARD_LIMIT, AD_REWARD_COIN
|
||||
def _last_completed_round_end_at(
|
||||
db: Session, user_id: int, reward_date: str, round_count: int
|
||||
) -> datetime | None:
|
||||
"""当日 granted 记录中**最近一个已完成轮**末尾那次的 created_at。
|
||||
|
||||
思路:把当日 granted 按时间倒序排,跳过当前未完成轮的 round_count 条,下一条
|
||||
即"上一轮最后一次"。round_count==0 且 used>=N 时跳 0 条直接取最近一条。
|
||||
used<N(还没完成第一轮)调用方应直接判 None,不进这里。
|
||||
"""
|
||||
return db.execute(
|
||||
select(AdRewardRecord.created_at)
|
||||
.where(
|
||||
AdRewardRecord.user_id == user_id,
|
||||
AdRewardRecord.reward_date == reward_date,
|
||||
AdRewardRecord.status == "granted",
|
||||
)
|
||||
.order_by(AdRewardRecord.created_at.desc())
|
||||
.offset(round_count)
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def today_status(
|
||||
db: Session, user_id: int
|
||||
) -> tuple[int, int, int, int, datetime | None]:
|
||||
"""客户端查"今日看广告发奖"进度。
|
||||
|
||||
返回 (今日已发次数, 每日上限, 单次金币, 本轮已看次数, 本轮冷却结束时间(UTC))。
|
||||
- round_count = used % VIDEO_ROUND_REQUIRED_COUNT,展示用(0..N-1)
|
||||
- cooldown_until 计算:取最近一个已完成轮末尾的 created_at + 10 min;若仍 > now 则返回,
|
||||
否则返回 None。冷却 = UX 约束(客户端 CTA 倒计时不可点),后端发奖逻辑不受影响。
|
||||
"""
|
||||
today = cn_today().isoformat()
|
||||
used = _granted_today(db, user_id, today)
|
||||
round_count = used % VIDEO_ROUND_REQUIRED_COUNT
|
||||
cooldown_until: datetime | None = None
|
||||
if used >= VIDEO_ROUND_REQUIRED_COUNT:
|
||||
last_end = _last_completed_round_end_at(db, user_id, today, round_count)
|
||||
if last_end is not None:
|
||||
# SQLAlchemy 在 SQLite 上拿到的 created_at 可能是 naive,统一按 UTC 解读再比较
|
||||
if last_end.tzinfo is None:
|
||||
last_end = last_end.replace(tzinfo=timezone.utc)
|
||||
cd_end = last_end + timedelta(seconds=VIDEO_ROUND_COOLDOWN_SECONDS)
|
||||
if cd_end > datetime.now(timezone.utc):
|
||||
cooldown_until = cd_end
|
||||
return used, DAILY_AD_REWARD_LIMIT, AD_REWARD_COIN, round_count, cooldown_until
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.order import OrderRecord
|
||||
from app.schemas.order import OrderReportRequest
|
||||
|
||||
|
||||
def create_order_report(
|
||||
db: Session, user_id: int, req: OrderReportRequest
|
||||
) -> tuple[OrderRecord, bool]:
|
||||
"""记录一条归因订单。返回 (记录, 是否为重复上报)。
|
||||
|
||||
幂等:同 (user_id, client_event_id) 已存在则直接返回旧记录,duplicated=True,不新增。
|
||||
"""
|
||||
existing = db.execute(
|
||||
select(OrderRecord).where(
|
||||
OrderRecord.user_id == user_id,
|
||||
OrderRecord.client_event_id == req.client_event_id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return existing, True
|
||||
|
||||
rec = OrderRecord(
|
||||
user_id=user_id,
|
||||
client_event_id=req.client_event_id,
|
||||
platform=req.platform,
|
||||
platform_package=req.platform_package,
|
||||
pay_channel=req.pay_channel,
|
||||
compared_price_cents=req.compared_price_cents,
|
||||
paid_amount_cents=req.paid_amount_cents,
|
||||
device_id=req.device_id,
|
||||
source="attribution",
|
||||
)
|
||||
db.add(rec)
|
||||
db.commit()
|
||||
db.refresh(rec)
|
||||
return rec, False
|
||||
|
||||
|
||||
def list_orders(
|
||||
db: Session, user_id: int, limit: int = 100, offset: int = 0
|
||||
) -> tuple[list[OrderRecord], int]:
|
||||
"""按 id 倒序返回某用户的归因订单 + 总数。"""
|
||||
total = db.scalar(
|
||||
select(func.count()).select_from(OrderRecord).where(OrderRecord.user_id == user_id)
|
||||
) or 0
|
||||
rows = (
|
||||
db.execute(
|
||||
select(OrderRecord)
|
||||
.where(OrderRecord.user_id == user_id)
|
||||
.order_by(OrderRecord.id.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return list(rows), int(total)
|
||||
@@ -17,6 +17,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.rewards import CN_TZ, cn_today
|
||||
from app.models.savings import SavingsRecord
|
||||
from app.schemas.order import OrderReportRequest
|
||||
|
||||
# 演示数据规模:最近 N 天每天 1 单(撑起"连续省钱"和"本周"),再补若干历史单
|
||||
_DEMO_STREAK_DAYS = 9
|
||||
@@ -65,6 +66,26 @@ def _all_records(db: Session, user_id: int) -> list[SavingsRecord]:
|
||||
return list(db.execute(stmt).scalars().all())
|
||||
|
||||
|
||||
def _has_real(db: Session, user_id: int) -> bool:
|
||||
"""该用户是否已有真实比价上报(source='compare')记录。"""
|
||||
return db.execute(
|
||||
select(SavingsRecord.id)
|
||||
.where(SavingsRecord.user_id == user_id, SavingsRecord.source == "compare")
|
||||
.limit(1)
|
||||
).first() is not None
|
||||
|
||||
|
||||
def _effective_records(db: Session, user_id: int) -> list[SavingsRecord]:
|
||||
"""统计口径:有真实(compare)记录就只用真实的;否则 seed 一批 demo 并用 demo 兜底。"""
|
||||
if _has_real(db, user_id):
|
||||
stmt = select(SavingsRecord).where(
|
||||
SavingsRecord.user_id == user_id, SavingsRecord.source == "compare"
|
||||
)
|
||||
return list(db.execute(stmt).scalars().all())
|
||||
ensure_seeded(db, user_id)
|
||||
return _all_records(db, user_id)
|
||||
|
||||
|
||||
def ensure_seeded(db: Session, user_id: int) -> None:
|
||||
"""该用户没有任何省钱记录时,幂等灌一批 demo 数据。"""
|
||||
exists = db.execute(
|
||||
@@ -108,8 +129,7 @@ def ensure_seeded(db: Session, user_id: int) -> None:
|
||||
|
||||
|
||||
def get_summary(db: Session, user_id: int) -> SavingsSummary:
|
||||
ensure_seeded(db, user_id)
|
||||
records = _all_records(db, user_id)
|
||||
records = _effective_records(db, user_id)
|
||||
total = sum(r.saved_amount_cents for r in records)
|
||||
count = len(records)
|
||||
avg = total // count if count else 0
|
||||
@@ -129,8 +149,7 @@ def _streak_days(dates: set) -> int:
|
||||
|
||||
|
||||
def get_battle(db: Session, user_id: int) -> SavingsBattle:
|
||||
ensure_seeded(db, user_id)
|
||||
records = _all_records(db, user_id)
|
||||
records = _effective_records(db, user_id)
|
||||
|
||||
today = cn_today()
|
||||
week_start = today - timedelta(days=today.weekday()) # 本周一
|
||||
@@ -155,10 +174,20 @@ def _compute_beat_percent(db: Session, user_id: int) -> int:
|
||||
全部省得比我少 → 100;只有自己一个用户 → 0(无可比)。
|
||||
"""
|
||||
rows = db.execute(
|
||||
select(SavingsRecord.user_id, func.sum(SavingsRecord.saved_amount_cents))
|
||||
.group_by(SavingsRecord.user_id)
|
||||
select(
|
||||
SavingsRecord.user_id,
|
||||
SavingsRecord.source,
|
||||
func.sum(SavingsRecord.saved_amount_cents),
|
||||
).group_by(SavingsRecord.user_id, SavingsRecord.source)
|
||||
).all()
|
||||
totals = {uid: (total or 0) for uid, total in rows}
|
||||
# 每用户口径与展示一致:有 compare 用 compare 之和,否则退回 demo 之和
|
||||
by_user: dict[int, dict[str, int]] = {}
|
||||
for uid, source, total in rows:
|
||||
by_user.setdefault(uid, {})[source] = total or 0
|
||||
totals = {
|
||||
uid: (d["compare"] if "compare" in d else d.get("demo", 0))
|
||||
for uid, d in by_user.items()
|
||||
}
|
||||
others = {uid: t for uid, t in totals.items() if uid != user_id}
|
||||
if not others:
|
||||
return 0
|
||||
@@ -174,9 +203,12 @@ def list_records(
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[SavingsRecord], int | None]:
|
||||
"""省钱明细分页(按 id 倒序,游标式)。"""
|
||||
ensure_seeded(db, user_id)
|
||||
"""省钱明细分页(按 id 倒序,游标式)。有真实(compare)记录只列真实,否则 demo 兜底。"""
|
||||
stmt = select(SavingsRecord).where(SavingsRecord.user_id == user_id)
|
||||
if _has_real(db, user_id):
|
||||
stmt = stmt.where(SavingsRecord.source == "compare")
|
||||
else:
|
||||
ensure_seeded(db, user_id)
|
||||
if cursor is not None:
|
||||
stmt = stmt.where(SavingsRecord.id < cursor)
|
||||
stmt = stmt.order_by(SavingsRecord.id.desc()).limit(limit)
|
||||
@@ -184,3 +216,47 @@ def list_records(
|
||||
items = list(db.execute(stmt).scalars().all())
|
||||
next_cursor = items[-1].id if len(items) == limit else None
|
||||
return items, next_cursor
|
||||
|
||||
|
||||
def create_from_report(
|
||||
db: Session, user_id: int, req: OrderReportRequest
|
||||
) -> tuple[SavingsRecord, bool]:
|
||||
"""真实比价上报 → 写入一条 savings_record(source='compare')。返回 (记录, 是否重复上报)。
|
||||
|
||||
省额 = 源平台原价 − 实付(下限 0);原价缺失则记 0。
|
||||
幂等:同 (user_id, client_event_id) 已存在则直接返回旧记录,duplicated=True,不新增。
|
||||
"""
|
||||
existing = db.execute(
|
||||
select(SavingsRecord).where(
|
||||
SavingsRecord.user_id == user_id,
|
||||
SavingsRecord.client_event_id == req.client_event_id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return existing, True
|
||||
|
||||
paid = req.paid_amount_cents
|
||||
original = req.original_price_cents
|
||||
saved = max(0, original - paid) if original is not None else 0
|
||||
rec = SavingsRecord(
|
||||
user_id=user_id,
|
||||
order_amount_cents=paid,
|
||||
saved_amount_cents=saved,
|
||||
original_price_cents=original,
|
||||
compared_price_cents=req.compared_price_cents,
|
||||
platform=req.platform,
|
||||
title=req.shop_name, # 明细卡标题用门店名
|
||||
shop_name=req.shop_name,
|
||||
dishes=req.dishes or [],
|
||||
pay_channel=req.pay_channel,
|
||||
platform_package=req.platform_package,
|
||||
source_platform_name=req.source_platform_name,
|
||||
source_deeplink=req.source_deeplink,
|
||||
client_event_id=req.client_event_id,
|
||||
device_id=req.device_id,
|
||||
source="compare",
|
||||
)
|
||||
db.add(rec)
|
||||
db.commit()
|
||||
db.refresh(rec)
|
||||
return rec, False
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
@@ -27,6 +29,14 @@ class AdRewardStatusOut(BaseModel):
|
||||
daily_limit: int = Field(..., description="每日发奖次数上限")
|
||||
remaining: int = Field(..., description="今日剩余可领次数")
|
||||
coin_per_ad: int = Field(..., description="看完一个激励视频发的金币")
|
||||
round_count: int = Field(
|
||||
..., description="本轮(3 次一组)已看次数,0..N-1。客户端可由它判断'刚刚看完一轮'"
|
||||
)
|
||||
cooldown_until: datetime | None = Field(
|
||||
None,
|
||||
description="本轮看完后 10 分钟冷却结束时间(UTC ISO),null 表示不在冷却。"
|
||||
"冷却是 UX 约束,后端发奖不受影响,客户端 CTA 据此显示 MM:SS 倒计时且不可点",
|
||||
)
|
||||
|
||||
|
||||
class TestGrantOut(BaseModel):
|
||||
@@ -39,3 +49,7 @@ class TestGrantOut(BaseModel):
|
||||
daily_limit: int = Field(..., description="每日发奖次数上限")
|
||||
remaining: int = Field(..., description="今日剩余可领次数")
|
||||
coin_per_ad: int = Field(..., description="看完一个激励视频发的金币")
|
||||
round_count: int = Field(..., description="本轮已看次数,详见 AdRewardStatusOut")
|
||||
cooldown_until: datetime | None = Field(
|
||||
None, description="本轮冷却结束时间(UTC),详见 AdRewardStatusOut"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class OrderReportRequest(BaseModel):
|
||||
"""客户端归因成功后的上报体。金额一律用「分」(int) 传,避免浮点误差。"""
|
||||
|
||||
client_event_id: str = Field(..., max_length=64, description="客户端幂等键(UUID)")
|
||||
platform: str = Field(..., max_length=32, description="平台展示名,如 美团")
|
||||
platform_package: str | None = Field(None, max_length=128, description="平台包名")
|
||||
pay_channel: str = Field(..., max_length=16, description="支付渠道 wechat/alipay")
|
||||
compared_price_cents: int = Field(..., ge=0, description="我们给出的比价价(分)")
|
||||
paid_amount_cents: int = Field(..., ge=0, description="实际支付金额(分)")
|
||||
device_id: str | None = Field(None, max_length=128)
|
||||
# ===== 比价时携带的记账信息(客户端从意图识别阶段缓存而来;旧版客户端可能不传,故全部可空)=====
|
||||
shop_name: str | None = Field(None, max_length=128, description="门店名,如 肯德基宅急送(天北路店)")
|
||||
dishes: list[str] = Field(default_factory=list, description="菜品名列表")
|
||||
original_price_cents: int | None = Field(None, ge=0, description="源平台原价(分),省额=原价−实付")
|
||||
source_platform_name: str | None = Field(None, max_length=32, description="源平台展示名,如 美团")
|
||||
source_deeplink: str | None = Field(None, max_length=512, description="源平台重进链接(预留,本期只存不展示)")
|
||||
|
||||
|
||||
class OrderReportOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
platform: str
|
||||
pay_channel: str
|
||||
compared_price_cents: int
|
||||
paid_amount_cents: int
|
||||
duplicated: bool = False
|
||||
|
||||
|
||||
class OrderListItem(BaseModel):
|
||||
"""订单明细一条(展示用)。saved_cents = 比价价 − 实付(下限 0)。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
platform: str
|
||||
pay_channel: str
|
||||
compared_price_cents: int
|
||||
paid_amount_cents: int
|
||||
saved_cents: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class OrderListOut(BaseModel):
|
||||
items: list[OrderListItem]
|
||||
total: int
|
||||
@@ -200,12 +200,15 @@ class SavingsRecordOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
order_amount_cents: int
|
||||
saved_amount_cents: int
|
||||
order_amount_cents: int # 实付金额(分)
|
||||
saved_amount_cents: int # 省下(分)
|
||||
original_price_cents: int | None = None # 源平台原价(分);demo 行为空
|
||||
platform: str | None = None
|
||||
title: str | None = None
|
||||
shop_name: str | None = None
|
||||
dishes: list[str] = []
|
||||
pay_channel: str | None = None # wechat/alipay;demo 行为空
|
||||
source_platform_name: str | None = None # 源平台名,如 美团
|
||||
created_at: datetime
|
||||
|
||||
|
||||
|
||||
+28
-4
@@ -3,7 +3,7 @@
|
||||
> Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770`
|
||||
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**
|
||||
> 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer <access_token>`
|
||||
> 最后更新:2026-05-27
|
||||
> 最后更新:2026-05-31
|
||||
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
|
||||
|
||||
---
|
||||
@@ -52,6 +52,15 @@
|
||||
| 32 | `GET /api/v1/ad/pangle-callback` | 验签 | [详情](./ad-pangle-callback.md) |
|
||||
| 33 | `GET /api/v1/ad/reward-status` | Bearer | [详情](./ad-reward-status.md) |
|
||||
| 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad-test-grant.md) |
|
||||
| **用户资料**(前缀 `/api/v1/user`) |||
|
||||
| 35 | `PATCH /api/v1/user/profile` | Bearer | [详情](./user-profile.md) |
|
||||
| 36 | `POST /api/v1/user/avatar` | Bearer | [详情](./user-avatar.md) |
|
||||
| 37 | `DELETE /api/v1/user` | Bearer | [详情](./user-delete.md) |
|
||||
| **帮助与反馈**(前缀 `/api/v1/feedback`) |||
|
||||
| 38 | `POST /api/v1/feedback` | Bearer | [详情](./feedback.md) |
|
||||
| **静态资源**(StaticFiles 挂载,见下方 `/media` 静态服务) |||
|
||||
| - | `GET /media/avatars/<file>` | 无 | 用户头像;返回二进制图片 |
|
||||
| - | `GET /media/feedback/<file>` | 无 | 反馈截图;返回二进制图片 |
|
||||
|
||||
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
|
||||
> `coupon/step` 及外卖比价的 `intent/recognize` / `price/step` 都透传到 pricebot-backend,**MVP 阶段均不鉴权**(device_id 透传,待补 JWT——见各接口详情)。
|
||||
@@ -99,9 +108,9 @@
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | int | 用户主键 |
|
||||
| `phone` | string | 手机号 |
|
||||
| `nickname` | string \| null | 昵称(当前无接口可改,恒为 null) |
|
||||
| `avatar_url` | string \| null | 头像(同上) |
|
||||
| `phone` | string | 手机号(注销账号后变 `deleted_<id>` 占位释放唯一约束) |
|
||||
| `nickname` | string \| null | 昵称,经 [`PATCH /api/v1/user/profile`](./user-profile.md) 修改 |
|
||||
| `avatar_url` | string \| null | 头像相对 URL(`/media/avatars/...`),经 [`POST /api/v1/user/avatar`](./user-avatar.md) 上传 |
|
||||
| `register_channel` | string | 注册渠道:`jverify` / `sms` |
|
||||
| `status` | string | `active` / `disabled` / `deleted` |
|
||||
| `created_at` | datetime | 注册时间 |
|
||||
@@ -135,6 +144,21 @@
|
||||
|
||||
---
|
||||
|
||||
## /media 静态服务
|
||||
|
||||
用户上传文件(头像/反馈截图)落盘到 `settings.MEDIA_ROOT`(默认 `./data/media/`),由 FastAPI 的 `StaticFiles` 挂在 `settings.MEDIA_URL_PREFIX`(默认 `/media`)对外暴露:
|
||||
|
||||
- `GET /media/avatars/<file>` — 用户头像(JPEG/PNG/WebP)
|
||||
- `GET /media/feedback/<file>` — 反馈截图(同上)
|
||||
|
||||
**生产建议**:由 nginx 直接 serve `MEDIA_ROOT` 目录,绕过应用进程减少压力。
|
||||
|
||||
**URL 格式**:服务端返回**相对路径**(如 `/media/avatars/u1_a4f2b3c8e9d2e0a1.jpg`),客户端按自己的 `BASE_URL` 拼绝对地址——dev 下可能是 `10.0.2.2`/LAN IP/`127.0.0.1`,服务端不知道客户端怎么访问到自己。
|
||||
|
||||
**文件名服务端随机生成** `u<user_id>_<16 位 hex>.<ext>`,杜绝路径穿越与覆盖。
|
||||
|
||||
---
|
||||
|
||||
## 附:鉴权与刷新机制
|
||||
|
||||
- **签发**:登录成功后签发 access(HS256,2h) + refresh(30d),payload 含 `sub`(user_id)、`typ`(access/refresh)、`iat`、`exp`。
|
||||
|
||||
@@ -14,6 +14,15 @@
|
||||
| `daily_limit` | int | 每日发奖次数上限 |
|
||||
| `remaining` | int | 今日剩余可领次数 |
|
||||
| `coin_per_ad` | int | 看完一个激励视频发的金币 |
|
||||
| `round_count` | int | 本轮(3 次一组)已看次数,0..N-1。客户端可据「拿到 round_count==0 && cooldown_until!=null」判定刚刚看完一轮 |
|
||||
| `cooldown_until` | datetime\|null | 本轮 10 分钟冷却结束时间(UTC ISO);null 表示不在冷却 |
|
||||
|
||||
## 说明
|
||||
福利页「看视频赚金币」用:展示「今日还能看 N 次」「看一次得 M 金币」。真正发奖走 [ad-pangle-callback](./ad-pangle-callback.md)(穿山甲 S2S)。
|
||||
福利页「看视频赚金币」用:
|
||||
- 展示「今日还能看 N 次」「看一次得 M 金币」(`remaining`/`coin_per_ad`)
|
||||
- 任务行 CTA 4 态推导:`adLoading` → Loading;`remaining==0` → Capped("明天再来");`cooldown_until` 在未来 → CoolingDown(显示 MM:SS 倒计时,不可点);否则 Normal("去赚取")
|
||||
- 弹窗 limit note:`used_today >= daily_limit` 显示「今日视频已到限额,明天再来」;`round_count==0 && cooldown_until!=null` 显示「本轮视频已看完,10分钟后再来」
|
||||
|
||||
冷却派生算法:取当日 `status='granted'` 记录里**最近一个已完成轮**(每 N=3 条算一轮)末尾那次的 `created_at`,加 10 分钟。若仍 > now → 返回;否则返回 null。**冷却是 UX 约束**,后端发奖(`/api/v1/ad/pangle-callback`)只看 daily_limit,冷却期间穿山甲回调照常发奖。
|
||||
|
||||
真正发奖走 [ad-pangle-callback](./ad-pangle-callback.md)(穿山甲 S2S)。
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# POST /api/v1/feedback — 提交反馈
|
||||
|
||||
> 所属:Feedback 组(前缀 `/api/v1/feedback`) | 鉴权:Bearer access_token | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参
|
||||
**multipart/form-data**:
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `content` | string | ✓ | 反馈正文,**1-2000 字**(strip 后) |
|
||||
| `contact` | string | ✓ | 联系方式(微信/QQ/手机号),**1-128 字**,便于回访 |
|
||||
| `images` | file[] | ✗ | 截图,**最多 4 张**,每张走头像同款校验(JPEG/PNG/WebP,≤ 5 MB,魔数嗅探) |
|
||||
|
||||
## 出参
|
||||
响应 `200`:
|
||||
```json
|
||||
{
|
||||
"id": 123,
|
||||
"status": "new",
|
||||
"created_at": "2026-05-29T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | int | 反馈单 ID |
|
||||
| `status` | string | `new`(待处理)/ `handled`(已处理) |
|
||||
| `created_at` | datetime | 提交时间(UTC) |
|
||||
|
||||
> 不返回上传的 image URL——这是给运营后台看的,客户端通常不需要。
|
||||
|
||||
## 错误码
|
||||
- `400` 内容为空 / 内容超 2000 字 / 联系方式为空 / 联系方式超 128 字 / 图片超 4 张 / 单图非法(空/过大/格式不对)
|
||||
- `401` 未带 token / token 无效或过期 / 用户被禁用
|
||||
- `422` 缺 `content` 或 `contact` 字段
|
||||
|
||||
## 说明
|
||||
- **反馈绑用户**:`feedback.user_id = current_user.id`,便于回访
|
||||
- **截图落盘**:存到 `settings.MEDIA_ROOT/feedback/`,URL 写进 `feedback.images`(JSON 列,SQLite 是 TEXT、PG 是 JSON/JSONB——为不影响现有迁移用 JSON 而非 JSONB)
|
||||
- **截图可读**:`GET /media/feedback/<file>` 静态服务暴露,详见 [API 索引 — /media 静态服务](./README.md#media-静态服务)
|
||||
- 文件名随机 `u<user_id>_<rand>.<ext>`,杜绝路径穿越
|
||||
@@ -23,7 +23,8 @@
|
||||
响应 `200`:`{ items: CouponCard[], has_next: bool, search_id: string|null }`。`CouponCard` 结构见 [API 索引](./README.md#复用数据结构)。
|
||||
|
||||
## 错误码
|
||||
- `502` 美团接口失败
|
||||
- `502` 美团接口失败(仅在**已配置 `MT_CPS_APP_KEY/APP_SECRET` 但调用失败**时;未配凭证场景见下)
|
||||
|
||||
## 说明
|
||||
当前 Android 客户端**未调用**此接口(搜索功能尚未实现),仅后端实现完整。
|
||||
- 当前 Android 客户端**未调用**此接口(搜索功能尚未实现),仅后端实现完整
|
||||
- **未配置 MT_CPS 凭证时降级**:`settings.mt_cps_configured == false` 时直接返 `{ items: [], has_next: false, search_id: null }`,**不报 502**。设计目的是让新开发机首屏不炸——后端业务层先不依赖 CPS。已配凭证但调美团失败时才走 502
|
||||
|
||||
@@ -17,9 +17,15 @@
|
||||
响应 `200`:`{ items: CouponCard[], has_next: bool, page: int }`。`CouponCard` 结构见 [API 索引](./README.md#复用数据结构)。
|
||||
|
||||
## 错误码
|
||||
无(见"说明"中的可观测性盲区)
|
||||
无(任何失败场景都返 200 + 空 items,见"说明"中的可观测性盲区)
|
||||
|
||||
## 说明
|
||||
后端在 `coupons` 之上封装的"伪推荐"——每页并发拉一次外卖、一次到店(到餐)榜单,按 **2 外卖 + 1 到店** 交叉去重。榜单组合写死 3 页(第1页爆款、第2页今日必推、第3页外卖精选+到店限时),**第 3 页起 `has_next=false`、第 4 页返空**。
|
||||
|
||||
⚠️ **可观测性盲区**:`feed` 内部对美团调用异常是静默吞掉返回空列表(不报 502、不打日志)。若 `items` 为空但无错误,优先用 `coupons` 接口逼出真实错误(它会以 502 暴露,如"MT_CPS_APP_KEY not configured")。
|
||||
**两种空结果路径**:
|
||||
- **未配置 `MT_CPS_APP_KEY/APP_SECRET`**:开头 `settings.mt_cps_configured` 判定后**直接返空**,完全不调美团
|
||||
- **已配置但美团调用失败**:`_fetch_topic` 的 `except MeituanCpsError` **静默返空**(不报 502、不打日志)
|
||||
|
||||
⚠️ **可观测性盲区**:两种路径在响应上**无法区分**。若 `items` 为空想区分:
|
||||
- 看 `settings.mt_cps_configured`(`/health` 接口有暴露此字段)→ 区分"配置缺失" vs "调用失败"
|
||||
- 改调 `coupons` 接口——它在"已配凭证但调用失败"时会 502 暴露错误细节(未配凭证它也降级返空,跟 feed 一致)
|
||||
|
||||
@@ -23,9 +23,9 @@
|
||||
| `link_map` | object | 各类型链接 `{ "linkType": "url" }`,如 `{"1": "<H5>", "3": "<deeplink>"}` |
|
||||
|
||||
## 错误码
|
||||
- `502` 美团接口失败
|
||||
- `502` 美团接口失败(仅在**已配置 `MT_CPS_APP_KEY/APP_SECRET` 但调用失败**时;未配凭证场景见下)
|
||||
|
||||
## 说明
|
||||
客户端实际优先用 `link_map["3"]`(deeplink)拉起美团 App,失败降级 `link_map["1"]`(H5)。
|
||||
|
||||
⚠️ **安全**:`sid` 允许客户端传值覆盖服务端默认渠道,理论上他人可借本接口刷自己渠道的分佣;建议服务端锁定 `sid`、忽略客户端传值。
|
||||
- 客户端实际优先用 `link_map["3"]`(deeplink)拉起美团 App,失败降级 `link_map["1"]`(H5)
|
||||
- **未配置 MT_CPS 凭证时降级**:`settings.mt_cps_configured == false` 时直接返 `{ link: "", link_map: {} }`,**不报 502**。客户端拿到空 link 时跳转会失败——客户端应做兜底(toast "暂无可用链接"或不显示「抢」按钮)
|
||||
- ⚠️ **安全**:`sid` 允许客户端传值覆盖服务端默认渠道,理论上他人可借本接口刷自己渠道的分佣;建议服务端锁定 `sid`、忽略客户端传值
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# POST /api/v1/user/avatar — 上传头像
|
||||
|
||||
> 所属:User 组(前缀 `/api/v1/user`) | 鉴权:Bearer access_token | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参
|
||||
**multipart/form-data**:
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `file` | file | 头像图片,**仅 JPEG / PNG / WebP**,**≤ 5 MB** |
|
||||
|
||||
> 服务端**按文件头魔数嗅探真实类型**(`FF D8 FF` / `89 50 4E 47` / `RIFF…WEBP`),不信任 Content-Type 与文件名;不在三种之内的一律拒。
|
||||
|
||||
## 出参
|
||||
响应 `200`:`UserOut`(`avatar_url` 字段已更新为新地址,形如 `/media/avatars/u<uid>_<rand>.jpg`)。结构见 [API 索引](./README.md#复用数据结构)。
|
||||
|
||||
> 返回**相对路径**——客户端按自己的 `BASE_URL` 拼绝对地址(dev 下 `10.0.2.2`/LAN IP 都可能,服务端不知道客户端怎么访问到自己)。读图走 [/media 静态服务](./README.md#media-静态服务) 的 `GET /media/...`。
|
||||
|
||||
## 错误码
|
||||
- `400` 空文件 / 超过 5 MB / 非 JPEG/PNG/WebP
|
||||
- `401` 未带 token / token 无效或过期 / 用户被禁用
|
||||
- `422` 缺 `file` 字段
|
||||
|
||||
## 说明
|
||||
- **旧头像清理**:落库成功后才删旧文件,避免"删了新的没存上"的丢图风险;非本服务托管的 URL(如微信头像)不动
|
||||
- **文件名服务端随机生成** `u<user_id>_<16 位 hex>.<ext>`,杜绝路径穿越与覆盖
|
||||
- 存储目录:`settings.MEDIA_ROOT/avatars/`,默认 `data/media/avatars/`(生产 nginx 注意把它放共享卷或对象存储,见 [后端技术实现.md](../后端技术实现.md))
|
||||
@@ -0,0 +1,18 @@
|
||||
# DELETE /api/v1/user — 注销账号
|
||||
|
||||
> 所属:User 组(前缀 `/api/v1/user`) | 鉴权:Bearer access_token | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参
|
||||
无(身份取自 Header token)
|
||||
|
||||
## 出参
|
||||
响应 `200`:`{ "ok": true }`
|
||||
|
||||
## 错误码
|
||||
- `401` 未带 token / token 无效或过期 / 用户被禁用(已注销账号再次调返 401,因 `status=deleted` 不再 active)
|
||||
|
||||
## 说明
|
||||
- **软删除 + 匿名化**:`status="deleted"` + `phone="deleted_<id>"`(释放 phone 唯一约束,允许同号码重新注册成全新账号)+ `nickname`/`avatar_url` 清空。行保留用于审计 / 关联表外键完整性
|
||||
- **头像文件物理删除**:本服务托管的头像文件(`/media/avatars/...`)同时删盘;微信头像等外部 URL 不动
|
||||
- **后续登录**:`status=deleted` 的行登录接口校验不过,所以"注销账号"后旧 token 仍能用直到自然过期(2h),但一过期就再也换不出新 token。客户端应在调本接口后立刻清本地 token
|
||||
- **未来若加 jti 黑名单**:本接口应同时把当前 access/refresh 加入黑名单实现"立即吊销"
|
||||
@@ -0,0 +1,20 @@
|
||||
# PATCH /api/v1/user/profile — 修改昵称
|
||||
|
||||
> 所属:User 组(前缀 `/api/v1/user`) | 鉴权:Bearer access_token | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参
|
||||
请求体 JSON:
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `nickname` | string | 昵称,**1-16 字**(前后空格自动 strip,strip 后为空判 400) |
|
||||
|
||||
## 出参
|
||||
响应 `200`:`UserOut`(返回更新后的完整用户对象,`nickname` 字段已更新)。结构见 [API 索引](./README.md#复用数据结构)。
|
||||
|
||||
## 错误码
|
||||
- `401` 未带 token / token 无效或过期 / 用户被禁用
|
||||
- `422` 字段校验失败(`nickname` 缺失 / 长度超 16 字 / 全空白)
|
||||
|
||||
## 说明
|
||||
- **服务端持久化**:写入 `user.nickname` 列。这是昵称的唯一数据源——客户端之前的本地缓存退登即丢,改完后下次登录走 [`/auth/me`](./auth-me.md) 与登录响应回传仍生效
|
||||
- **strip 行为**:服务端会先去除前后空白再校验长度,所以 `" bob "` 等价于 `"bob"`
|
||||
@@ -33,5 +33,18 @@ METHOD\n + Content-MD5\n + Headers(按 key 升序 "k:v\n") + Url
|
||||
| `MT_CPS_DEFAULT_SID` | 默认推广位 sid(渠道) |
|
||||
| `MT_CPS_TIMEOUT_SEC` | 请求超时 |
|
||||
|
||||
## 未配凭证时降级(2026-05-28 引入)
|
||||
api 层 3 个端点都在入口处 `if not settings.mt_cps_configured:` 早返空(`coupons` 返空数组、`feed` 返空 items、`referral-link` 返 `link=""` + `link_map={}`),**不报 502**。
|
||||
|
||||
**为什么这么设计**:新开发机没填 `MT_CPS_APP_KEY` 时,首页一进就拉 `/feed` → 502 → 整屏错误,首屏体验差。降级后端业务可以先跑起来,等真要测美团 CPS 再补 key。
|
||||
|
||||
**坏副作用**:`feed` 本来就有"已配凭证但美团调用失败时 `_fetch_topic` 静默吞错"的可观测性盲区,加上"未配凭证时也返空",**两种空结果路径在响应上无法区分**。排障路径:
|
||||
- `/health` 接口暴露 `mt_cps_configured: bool`——先看配置
|
||||
- 改调 `/coupons` 让"已配凭证但调用失败"以 502 + 错误文案暴露细节
|
||||
|
||||
## feed 调用静默吞错
|
||||
`api/v1/meituan.py` 的 `_fetch_topic` 对 `MeituanCpsError` `except 返回 []` 且**不打日志**。导致美团接口失败时 `feed` 返 200 + 空 items 无声失败。建议补一条 `logger.error` 至少留痕,真实排障还得改调 `coupons`(它在配置就绪时以 502 暴露)。
|
||||
|
||||
## 备注
|
||||
美团接口当前**无鉴权**,且换链的 `sid` 允许客户端传值覆盖默认渠道——见对应 api 文档「备注」。
|
||||
- 美团接口当前**无鉴权**,且换链的 `sid` 允许客户端传值覆盖默认渠道——见对应 api 文档「备注」
|
||||
- 未配凭证降级、feed 静默吞错都在 [待办与技术债.md](../待办与技术债.md) 已记账
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
# SQLite → PostgreSQL 迁移指南
|
||||
|
||||
> 上线前把数据库从 SQLite 切到 PostgreSQL。本服务自始就为这一步预留了路径(`db/session.py` 的 dialect 特判 / `alembic/env.py` 的 batch mode 开关 / SQLAlchemy 2.0 + Alembic),代码改动很小。
|
||||
>
|
||||
> **相关文档**:
|
||||
> - 这份文档讲 **切引擎的完整步骤**(本机装 PG / 建库 / `scripts/init_postgres.py` 用法 / 生产切换)
|
||||
> - [数据库迁移.md](./数据库迁移.md) 讲 **alembic 操作**(如何建表/升级/新增迁移/多 head 排查/迁移链 12 条)
|
||||
> - [后端技术实现.md §10](./后端技术实现.md) 列待办与已知问题(含 `init_postgres.py` 已知小 bug)
|
||||
>
|
||||
> **背景假设**:当前生产**无真实用户数据**(MVP 阶段),所以整个迁移本质是"切引擎",不涉及数据搬迁。如果将来已有真实数据,本文档不适用,需补充 `pgloader` 演练 + 钱表金额逐行核对 + 停服窗口 + 回滚预案。
|
||||
>
|
||||
> 最后更新:2026-05-31
|
||||
|
||||
---
|
||||
|
||||
## 0. 为什么切
|
||||
|
||||
SQLite 三个硬伤决定它不能上线:
|
||||
|
||||
- **并发写锁库**:整个文件一把锁,多请求并发写会排队,极易超时。当前 uvicorn `--workers 1` 勉强能跑,扩 worker 立刻撞墙。
|
||||
- **类型宽松**:整数列能塞字符串、`CHECK` 约束当装饰品。钱表(`wallet` / `withdraw_order` / `cash_transaction`)用 SQLite 等于裸奔。
|
||||
- **无 JSONB / 无并发索引 / 无分区**:PG 的核心能力一个都享受不到。`savings.dishes` 现在用 SQLite 的 `JSON`(实际存 TEXT),无索引、无操作符,纯展示用。
|
||||
|
||||
PG 默认上 16 版(工具链最齐),驱动用 **psycopg3**(SQLAlchemy 2.0 时代默认,不要再装 psycopg2)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 本地起 PG + 跑通空库(半天)
|
||||
|
||||
### 1.1 装 PG
|
||||
|
||||
macOS:
|
||||
```bash
|
||||
brew install postgresql@16
|
||||
brew services start postgresql@16
|
||||
```
|
||||
|
||||
Linux(Ubuntu/Debian):
|
||||
```bash
|
||||
sudo apt install postgresql-16
|
||||
sudo systemctl enable --now postgresql
|
||||
```
|
||||
|
||||
### 1.2 建库 + 用户 + 授权
|
||||
|
||||
```bash
|
||||
psql postgres <<'EOF'
|
||||
CREATE USER shaguabijia_app WITH PASSWORD 'change-me-strong-random';
|
||||
CREATE DATABASE shaguabijia OWNER shaguabijia_app ENCODING 'UTF8';
|
||||
GRANT ALL PRIVILEGES ON DATABASE shaguabijia TO shaguabijia_app;
|
||||
EOF
|
||||
```
|
||||
|
||||
密码用 `python -c "import secrets; print(secrets.token_urlsafe(32))"` 生成,**不要复用 JWT_SECRET_KEY**。
|
||||
|
||||
### 1.3 装驱动
|
||||
|
||||
```bash
|
||||
pip install "psycopg[binary]>=3.1"
|
||||
```
|
||||
|
||||
`psycopg[binary]` 是预编译版,免装 libpq 头文件。生产同款,无需源码编译版。
|
||||
|
||||
### 1.4 改 `.env`
|
||||
|
||||
```ini
|
||||
DATABASE_URL=postgresql+psycopg://shaguabijia_app:<password>@localhost:5432/shaguabijia
|
||||
```
|
||||
|
||||
> ⚠️ URL scheme 必须是 `postgresql+psycopg://`(显式声明 psycopg3),不能写成 `postgresql://`——SQLAlchemy 默认会去找 psycopg2,装的不一致就报 ModuleNotFoundError。
|
||||
|
||||
### 1.5 建表
|
||||
|
||||
```bash
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
### 1.6 验证
|
||||
|
||||
```bash
|
||||
psql -U shaguabijia_app -d shaguabijia -c "\dt"
|
||||
```
|
||||
|
||||
应该列出全部 9 张表 + `alembic_version`:
|
||||
```
|
||||
user, coin_account, coin_transaction, cash_transaction,
|
||||
withdraw_order, signin_record, task_claim_record,
|
||||
savings_record, ad_reward_record, alembic_version
|
||||
```
|
||||
|
||||
再手动 INSERT 一行 user,确认 `created_at` 默认值正常落:
|
||||
```sql
|
||||
INSERT INTO "user" (phone, register_channel, status) VALUES ('13800138000', 'sms', 'active');
|
||||
SELECT id, phone, created_at, last_login_at FROM "user";
|
||||
```
|
||||
|
||||
`created_at` 应该是当前时间戳带时区,不是 NULL。`user` 表名是 PG 关键字,**必须用双引号**包裹。
|
||||
|
||||
---
|
||||
|
||||
## 2. 测试套件全绿(1 小时)
|
||||
|
||||
### 2.1 测试连 PG
|
||||
|
||||
测试默认连 SQLite。临时让 `tests/conftest.py` 把 `DATABASE_URL` 也指到本地 PG(可以建个 `shaguabijia_test` 库专门跑测试,免得污染 dev 库)。
|
||||
|
||||
```bash
|
||||
createdb -O shaguabijia_app shaguabijia_test
|
||||
DATABASE_URL=postgresql+psycopg://shaguabijia_app:xxx@localhost:5432/shaguabijia_test pytest
|
||||
```
|
||||
|
||||
### 2.2 处理失败用例
|
||||
|
||||
**所有红色测试都不能放过**——SQLite 类型/约束都很宽松,PG 严格起来暴露的就是真 bug。常见问题:
|
||||
|
||||
- **字符串和整数比较**:SQLite 允许 `WHERE phone = 13800138000`(自动转字符串),PG 直接报错。改成 `:phone` 参数 + 类型严格
|
||||
- **timezone 处理**:SQLite 存 naive datetime,PG 的 `TIMESTAMPTZ` 必须带时区。检查所有 `datetime.utcnow()`,改成 `datetime.now(timezone.utc)`
|
||||
- **隐式 commit**:SQLite 某些情况下隐式 commit,PG 严格事务边界。如果某用例报 `current transaction is aborted`,说明业务代码缺 commit/rollback
|
||||
|
||||
### 2.3 验证
|
||||
|
||||
`pytest` 全绿,**且本地起 `./run.sh` 后用 Apifox 调通登录 / 美团 feed / 领券透传三个真接口**。
|
||||
|
||||
---
|
||||
|
||||
## 3. 代码扫雷(1 天)
|
||||
|
||||
### 3.1 必改清单
|
||||
|
||||
| 文件 / 位置 | 改动 | 原因 |
|
||||
|---|---|---|
|
||||
| `app/models/savings.py` | `from sqlalchemy import JSON` → `from sqlalchemy.dialects.postgresql import JSONB`;`mapped_column(JSON, ...)` → `mapped_column(JSONB, ...)` | JSONB 在 PG 上有 GIN 索引和操作符支持,是 PG 核心优势之一。SQLite 上 `JSON` 实际是 TEXT,切到 PG 后用 `JSON` 类型存的是 `json` 不是 `jsonb`,查询/索引能力差一大截 |
|
||||
| `app/db/session.py` 的 `create_engine` 调用 | 增加连接池参数:`pool_size=10, max_overflow=20, pool_recycle=3600` | SQLite 单文件不需要池,PG 必须显式池化。`pool_recycle=3600` 防 PG 主动断开的 idle 连接 |
|
||||
| `alembic/versions/` 下旧迁移里的 `sa.text('(CURRENT_TIMESTAMP)')` | **不要改** | PG 能接受这个语法。改了反而搞乱迁移历史 + 让 alembic 觉得 schema drift。新写的迁移统一用 `sa.func.now()` |
|
||||
|
||||
### 3.2 保留的 dialect 特判
|
||||
|
||||
下面这些**保留原样**,它们的 SQLite 分支在 PG 下自动绕过、不影响新部署,但本地开发想用 SQLite 临时跑还能用:
|
||||
|
||||
- `app/db/session.py` 里的 `_ensure_sqlite_dir` 和 `check_same_thread` 特判
|
||||
- `alembic/env.py` 里的 `render_as_batch` 特判
|
||||
|
||||
### 3.3 顺便清掉
|
||||
|
||||
`app/main.py` 启动日志里如果打 `DATABASE_URL`,确认密码不会进日志(只打 scheme + host,不打完整 URL)。
|
||||
|
||||
### 3.4 验证
|
||||
|
||||
本地 PG 起 `./run.sh`,跑一遍下面的烟测脚本:
|
||||
|
||||
```bash
|
||||
# 登录
|
||||
curl -X POST http://localhost:8770/api/v1/auth/sms/send -d '{"phone":"13800138000"}'
|
||||
curl -X POST http://localhost:8770/api/v1/auth/sms/login -d '{"phone":"13800138000","code":"123456"}'
|
||||
# 拿到 access_token 后调 me
|
||||
curl -H "Authorization: Bearer <token>" http://localhost:8770/api/v1/auth/me
|
||||
# 美团 feed
|
||||
curl -X POST http://localhost:8770/api/v1/meituan/feed -d '{"longitude":116.4,"latitude":39.9,"page":0}'
|
||||
```
|
||||
|
||||
三条都 200 即通过。`psql` 看 `user` 表多了一条 `13800138000`。
|
||||
|
||||
---
|
||||
|
||||
## 4. 生产切换(半小时,无需停服窗口)
|
||||
|
||||
**前提**:生产无真实数据。如已有数据,本节不适用。
|
||||
|
||||
### 4.1 装 PG
|
||||
|
||||
```bash
|
||||
ssh server
|
||||
sudo apt install postgresql-16
|
||||
sudo systemctl enable --now postgresql
|
||||
```
|
||||
|
||||
### 4.2 建库
|
||||
|
||||
```bash
|
||||
sudo -u postgres psql <<'EOF'
|
||||
CREATE USER shaguabijia_app WITH PASSWORD '<strong-random-password>';
|
||||
CREATE DATABASE shaguabijia OWNER shaguabijia_app ENCODING 'UTF8';
|
||||
GRANT ALL PRIVILEGES ON DATABASE shaguabijia TO shaguabijia_app;
|
||||
EOF
|
||||
```
|
||||
|
||||
生产密码**单独生成**,不要复用本地的。
|
||||
|
||||
### 4.3 装驱动到生产 venv
|
||||
|
||||
```bash
|
||||
cd /opt/shaguabijia-app-server
|
||||
.venv/bin/pip install "psycopg[binary]>=3.1"
|
||||
```
|
||||
|
||||
### 4.4 改生产 `.env`
|
||||
|
||||
```ini
|
||||
DATABASE_URL=postgresql+psycopg://shaguabijia_app:<password>@localhost:5432/shaguabijia
|
||||
```
|
||||
|
||||
### 4.5 建表 + 重启
|
||||
|
||||
```bash
|
||||
.venv/bin/alembic upgrade head
|
||||
sudo systemctl restart shaguabijia-app-server
|
||||
sudo journalctl -u shaguabijia-app-server -f
|
||||
```
|
||||
|
||||
启动日志里看到 `Application startup complete` + 没有 5xx 即通过。
|
||||
|
||||
### 4.6 验证
|
||||
|
||||
- 用真账号(手机号)走一遍极光登录或短信登录
|
||||
- 调 `GET /api/v1/auth/me`,确认返回当前用户
|
||||
- 调 `POST /api/v1/meituan/feed`,确认券列表正常
|
||||
- `psql -U shaguabijia_app -d shaguabijia -c 'SELECT id, phone, created_at FROM "user"'` 看到刚登录的用户
|
||||
|
||||
### 4.7 老 SQLite 文件归档
|
||||
|
||||
```bash
|
||||
cd /opt/shaguabijia-app-server
|
||||
mv data/app.db data/app.db.legacy-$(date +%Y%m%d)
|
||||
```
|
||||
|
||||
不要删——万一要回查测试期残留数据(开发期可能塞了一些手工记录),保留几个月。
|
||||
|
||||
---
|
||||
|
||||
## 5. 兜底:切换失败如何回滚
|
||||
|
||||
万一改完起不来:
|
||||
|
||||
```bash
|
||||
# 1. .env 改回 SQLite
|
||||
sed -i 's|^DATABASE_URL=postgresql.*|DATABASE_URL=sqlite:///./data/app.db|' /opt/shaguabijia-app-server/.env
|
||||
|
||||
# 2. 把归档的 sqlite 文件还原(如果已经改名)
|
||||
mv data/app.db.legacy-* data/app.db
|
||||
|
||||
# 3. 重启
|
||||
sudo systemctl restart shaguabijia-app-server
|
||||
```
|
||||
|
||||
由于 SQLite 文件没动过,无数据丢失,损失只是切换过程中的几分钟 5xx。
|
||||
|
||||
---
|
||||
|
||||
## 6. 顺便建议:同时装上 Redis
|
||||
|
||||
PG 上线后,Redis 是下一个明确要补的基础设施(已在 [待办与技术债.md](./待办与技术债.md) 里列了用途)。**建议本次维护窗口顺手把 Redis 也装上**,不开始用、只装服务,免得下次还要单独申请部署:
|
||||
|
||||
```bash
|
||||
sudo apt install redis-server
|
||||
sudo systemctl enable --now redis-server
|
||||
# 改 /etc/redis/redis.conf:bind 127.0.0.1(只本机访问)+ requirepass <strong-password>
|
||||
sudo systemctl restart redis-server
|
||||
```
|
||||
|
||||
下面三个用途等接的时候再写代码,**本次只装服务、不接代码**:
|
||||
|
||||
| 用途 | 当前临时方案 | 何时必须接 Redis |
|
||||
|---|---|---|
|
||||
| 短信验证码冷却 | 进程内存 dict(`integrations/sms.py`) | `SMS_MOCK=false` 上线时 / 扩 worker 时 |
|
||||
| pricebot trace_id session | 进程内存 dict(在 pricebot-backend) | pricebot 扩 worker 时 |
|
||||
| JWT 黑名单(logout 真失效) | 当前 logout 占位无失效 | P1 鉴权改造时 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 后续优化(不阻塞上线,一周内补)
|
||||
|
||||
- **每天凌晨 `pg_dump` 备份**:用 systemd timer 或 cron,保留 7 天。备份文件 `pg_dump -Fc shaguabijia > /backup/shaguabijia-$(date +%Y%m%d).dump`
|
||||
- **金钱表加 CHECK 约束**:新迁移给 `coin_account.balance`、`withdraw_order.amount_yuan_x100` 加 `CHECK (... >= 0)`。PG 严格执行,SQLite 形同虚设
|
||||
- **timezone 统一**:生产 PG 配 `timezone='Asia/Shanghai'` 或维持 `UTC`,**团队定一个**写进本文档,业务代码统一处理。当前模型混用 `func.now()`(server-side)和 `_utcnow()`(`models/user.py` 的 `last_login_at`,Python-side),后者依赖应用层时区,迁完 PG 后建议统一改 `func.now()`
|
||||
- **慢查询观察**:`shared_preload_libraries = 'pg_stat_statements'` 打开扩展,每周看一次 Top 10 慢查询,提前优化
|
||||
|
||||
---
|
||||
|
||||
## 8. 不要做的事
|
||||
|
||||
- ❌ 不要搞主从复制 / 读写分离——量级远没到,先用单实例
|
||||
- ❌ 不要装 PgBouncer / 连接池中间件——SQLAlchemy 自带池够用
|
||||
- ❌ 不要 squash 旧迁移文件——9 个增量保持原样,让 alembic 一次跑过空库
|
||||
- ❌ 不要在生产 PG 上裸跑测试套件——用单独的 `shaguabijia_test` 库
|
||||
+81
-28
@@ -3,21 +3,27 @@
|
||||
> 域名:`app-api.shaguabijia.com`(HTTPS,nginx 反代)
|
||||
> 仓库:`shaguabijia-app-server`
|
||||
> 接口协议详见 [`docs/api/`](./api/)(索引 + 一接口一文件)
|
||||
> 最后更新:2026-05-27
|
||||
> 最后更新:2026-05-31
|
||||
|
||||
---
|
||||
|
||||
## 1. 这个后端是干什么的
|
||||
|
||||
为正式版 App(`shaguabijia-app-android`,包名 `com.jishisongfu.shaguabijia`)提供三块能力:
|
||||
为正式版 App(`shaguabijia-app-android`,包名 `com.jishisongfu.shaguabijia`)提供以下能力:
|
||||
|
||||
| 能力 | 说明 |
|
||||
|---|---|
|
||||
| **账号与登录** | 极光一键登录 + 短信验证码登录(mock)→ 签发 JWT |
|
||||
| **美团 CPS 选品** | 透传美团联盟优惠券(外卖/到店)、点击换取推广链接(分佣) |
|
||||
| **用户资料** | 昵称 / 头像(上传图片含魔数嗅探) / 注销账号(软删除+匿名化) |
|
||||
| **美团 CPS 选品** | 透传美团联盟优惠券(外卖/到店)、点击换取推广链接(分佣)、未配凭证时降级返空 |
|
||||
| **领券透传** | `/coupon/step` 透传到 pricebot-backend(一键领券核心,MVP 不鉴权,前端已接通) |
|
||||
| **外卖比价透传** | `/intent/recognize` + `/price/step` 透传到 pricebot-backend(food MVP,MVP 不鉴权) |
|
||||
| **金币 / 现金钱包** | 金币账户/流水/兑换/微信绑定/提现单 11 端点 |
|
||||
| **签到 + 任务 + 省钱战绩** | 福利模块 |
|
||||
| **看广告发奖** | 穿山甲 GroMore 激励视频 S2S 回调(SHA256 验签)+ 4 态 CTA 冷却 |
|
||||
| **帮助与反馈** | 用户提交反馈(含截图)+ 静态 `/media` 服务 |
|
||||
|
||||
**本服务自身没有"比价"实现**——名字叫比价,实质是:登录态 + 美团券聚合分佣 + 给真正的领券/比价核心(pricebot-backend,另一个 repo)做"透传壳"。无爬虫、无 LLM、无积分/提现/钱包,数据模型仅一张 `user` 表(美团数据与领券请求均实时透传不落库)。
|
||||
**本服务自身没有"比价"实现**——比价/领券业务的核心都在 pricebot-backend(另一个 repo),本服务对 step 类接口做"透传壳"。无爬虫、无 LLM,但**有钱包/福利等业务模型**(数据模型从早期 1 张 `user` 表已扩展到 10 张,见 §7)。
|
||||
|
||||
---
|
||||
|
||||
@@ -30,7 +36,8 @@
|
||||
| ASGI | uvicorn[standard] | 生产 `--host 127.0.0.1 --port 8770` |
|
||||
| ORM | SQLAlchemy 2.0(Mapped 风格) | |
|
||||
| 迁移 | Alembic | |
|
||||
| DB | SQLite 起步 | 改 `DATABASE_URL` 可切 PostgreSQL,无 Redis |
|
||||
| DB | **PostgreSQL 16(生产)/ SQLite(开发兜底)** | 一键脚本 `scripts/init_postgres.py` 建库 + 写 `.env` + 跑迁移;开发环境改 `DATABASE_URL=sqlite:///./data/app.db` 即回退。无 Redis。详见 [docs/postgres-migration.md](./postgres-migration.md) |
|
||||
| DB 驱动 | psycopg3(`psycopg[binary]>=3.1`) | `DATABASE_URL=postgresql+psycopg://...`,**不要装 psycopg2**——SQLAlchemy 2.0 时代默认 psycopg3 |
|
||||
| Auth | PyJWT(HS256) | access + refresh |
|
||||
| 极光 | httpx + cryptography | REST 验 token + RSA 解密手机号 |
|
||||
| 美团 / pricebot | httpx(含 async) | 美团 S-Ca 网关签名;领券 async 透传 |
|
||||
@@ -50,16 +57,20 @@ app/
|
||||
│ ├── deps.py # 共享依赖:get_current_user(鉴权)、get_db(注入 session)
|
||||
│ └── v1/ # 接口层(薄):解析请求 → 调 repositories/integration → 组装响应 + HTTP 错误码
|
||||
│ ├── auth.py # 登录 6 端点(极光一键登录 / 短信 send+login / refresh / me / logout)
|
||||
│ ├── user.py # 用户资料 3 端点(改昵称 / 上传头像 / 注销账号)
|
||||
│ ├── feedback.py # 帮助与反馈 1 端点(提交反馈含截图)
|
||||
│ ├── coupon.py # 领券透传 /coupon/step(转发 pricebot,MVP 不鉴权)
|
||||
│ ├── compare.py # 外卖比价透传 /intent/recognize + /price/step(转发 pricebot,MVP 不鉴权)
|
||||
│ ├── meituan.py # 美团 3 端点 + feed 拼接(_interleave / _TOPIC_ROUNDS)
|
||||
│ ├── meituan.py # 美团 3 端点 + feed 拼接(_interleave / _TOPIC_ROUNDS),未配 MT_CPS 凭证降级返空
|
||||
│ ├── wallet.py # 钱包/提现 11 端点(余额/流水/兑换/绑微信/提现/查单)
|
||||
│ ├── signin.py # 签到 2 端点(状态 / 执行签到)
|
||||
│ ├── tasks.py # 一次性任务 2 端点(列表 / 领取)
|
||||
│ ├── savings.py # 省钱 3 端点(汇总 / 战绩 / 明细)
|
||||
│ └── ad.py # 看广告发奖 3 端点(穿山甲 S2S 回调 / 进度 / 联调发奖)
|
||||
│ └── ad.py # 看广告发奖 3 端点(穿山甲 S2S 回调 / 进度+本轮冷却 / 联调发奖)
|
||||
├── schemas/ # Pydantic:API 收发的数据契约(与客户端对齐字段看这里)
|
||||
│ ├── auth.py
|
||||
│ ├── user.py # 改昵称请求 + OkResponse
|
||||
│ ├── feedback.py # 反馈出参(请求是 multipart,在 router 直接校验)
|
||||
│ ├── meituan.py
|
||||
│ ├── welfare.py # 钱包/签到/任务/省钱 收发模型
|
||||
│ └── ad.py # 看广告发奖收发模型
|
||||
@@ -73,35 +84,45 @@ app/
|
||||
│ ├── config.py # pydantic-settings
|
||||
│ ├── security.py # JWT 签发/校验
|
||||
│ ├── ratelimit.py # 同 IP 滑动窗口限流依赖
|
||||
│ ├── rewards.py # 发奖/兑换/提现额度等业务常量与换算
|
||||
│ ├── rewards.py # 发奖/兑换/提现额度等业务常量与换算(2026-05 加 VIDEO_ROUND_REQUIRED_COUNT / VIDEO_ROUND_COOLDOWN_SECONDS)
|
||||
│ ├── media.py # 用户上传文件(头像/反馈截图)落盘 + 魔数嗅探 + 随机文件名
|
||||
│ └── logging.py
|
||||
├── repositories/ # 数据访问 + 事务(早期叫 crud,2026-05 统一并入此目录)
|
||||
│ ├── user.py # get_user_by_id / get_user_by_phone / upsert_user_for_login
|
||||
│ ├── user.py # get_user_by_id / by_phone / upsert_for_login / update_nickname / set_avatar_url / soft_delete_account
|
||||
│ ├── feedback.py # 提交反馈写库
|
||||
│ ├── wallet.py # 账户/流水/兑换/提现单(调 integrations/wxpay)
|
||||
│ ├── signin.py # 签到记录 / 连续天数 / 档位
|
||||
│ ├── task.py # 一次性任务领取
|
||||
│ ├── savings.py # 省钱汇总 / 战绩 / 明细
|
||||
│ └── ad_reward.py # 看广告发奖(按 trans_id 幂等 + 每日上限)
|
||||
│ └── ad_reward.py # 看广告发奖(按 trans_id 幂等 + 每日上限 + 本轮冷却派生)
|
||||
├── models/ # ORM 表结构
|
||||
│ ├── user.py # user(含微信 openid/nickname/avatar)
|
||||
│ ├── feedback.py # 用户反馈(content/contact/images JSON 列/status)
|
||||
│ ├── wallet.py # 金币账户 / 金币流水 / 现金流水 / 提现单
|
||||
│ ├── signin.py # 签到记录
|
||||
│ ├── task.py # 任务领取记录
|
||||
│ ├── savings.py # 省钱明细 / 店铺菜品
|
||||
│ ├── savings.py # 省钱明细 / 店铺菜品 / dishes(PG 上 JSONB)
|
||||
│ └── ad_reward.py # 看广告发奖记录
|
||||
└── db/
|
||||
├── base.py # DeclarativeBase
|
||||
└── session.py # engine + get_db
|
||||
└── session.py # engine + get_db(非 SQLite 时启 pool: size=10/overflow=20/recycle=3600)
|
||||
|
||||
alembic/ # 数据库迁移(versions/ 9 个迁移;文件名已去 hex 前缀,链靠文件内 down_revision)
|
||||
alembic/ # 数据库迁移(versions/ 11+ 个迁移含 feedback_table / convert_dishes_jsonb / merge 等)
|
||||
deploy/ # systemd(.service) + nginx(.conf)
|
||||
secrets/ # 极光 RSA 私钥 / 微信支付证书(不入 git,仅 .gitkeep 占位)
|
||||
scripts/ # 运维脚本(migrate 迁移 / 对账 / 重置签到 / 重置福利 / 模拟穿山甲回调)
|
||||
tests/ # pytest(auth / health / welfare / withdraw / ad_reward / coupon_proxy)
|
||||
scripts/
|
||||
├── init_postgres.py # 一键 PG 初始化:建用户 + 建库 + 写 .env + 跑迁移(2026-05 新增,见已知 bug §10)
|
||||
├── migrate.sh # 单独跑 alembic upgrade head(部署/CI 用)
|
||||
├── reset_signin.py # 重置今日签到
|
||||
├── reset_welfare.py # 重置福利数据
|
||||
├── reconcile_withdraws.py # 提现对账
|
||||
└── sim_pangle_callback.py # 模拟穿山甲回调
|
||||
tests/ # pytest(auth / health / welfare / withdraw / ad_reward / coupon_proxy / compare_proxy)
|
||||
run.sh # 本地启动脚本(自动先跑迁移再起服务)
|
||||
docs/api/ # API 接口文档(索引 README + 一接口一文件)
|
||||
docs/integrations/ # 集成层实现文档(SDK 签名/加解密/协议细节)
|
||||
docs/数据库迁移.md # Alembic 迁移指南(如何建表/升级/新增迁移)
|
||||
docs/postgres-migration.md # SQLite → PostgreSQL 切换指南(配套 scripts/init_postgres.py)
|
||||
```
|
||||
|
||||
> **命名说明**:`api/v1/` 的 `v1` 用于 URL 版本化(移动端无法强制即时升级,需新旧版本并存能力);`integrations` 装外部 SDK 集成、`repositories` 装数据访问、`core` 装基础设施,三者分离。**数据访问层统一在 `repositories/`**(早期叫 `crud/`,2026-05 已整体并入,`crud/` 不再存在)。`coupon.py` 是领券透传,勿与 `meituan.py` 里的 `coupons`(券列表)混淆。
|
||||
@@ -197,41 +218,70 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
|
||||
|
||||
## 7. 数据模型
|
||||
|
||||
仅一张 `user` 表(`models/user.py`):
|
||||
10 张业务表 + `alembic_version` 框架表。生产 PG / 开发可回退 SQLite。
|
||||
|
||||
| 表 | models 文件 | 说明 |
|
||||
|---|---|---|
|
||||
| `user` | `models/user.py` | 用户(phone 唯一索引;注销时改成 `deleted_<id>` 释放约束) |
|
||||
| `feedback` | `models/feedback.py` | 用户反馈(content/contact/images JSON 列/status) |
|
||||
| `coin_account` | `models/wallet.py` | 用户的金币余额(`balance_cents` 单位:分) |
|
||||
| `coin_transaction` | `models/wallet.py` | 金币流水(签到/任务/兑换/广告;`type` + `amount_cents`) |
|
||||
| `cash_transaction` | `models/wallet.py` | 现金流水(兑换/提现) |
|
||||
| `withdraw_order` | `models/wallet.py` | 提现单(`status` + `wxpay_batch_id`,微信支付商家转账) |
|
||||
| `signin_record` | `models/signin.py` | 签到记录(每日一行,索引 `(user_id, signin_date)`) |
|
||||
| `user_task` | `models/task.py` | 一次性任务领取记录 |
|
||||
| `savings_record` | `models/savings.py` | 省钱明细(`dishes` 在 PG 上为 JSONB,SQLite 上是 TEXT) |
|
||||
| `ad_reward_record` | `models/ad_reward.py` | 看广告发奖(`trans_id` 唯一,幂等) |
|
||||
|
||||
**user 表字段速查**:
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | INTEGER PK | 自增 |
|
||||
| phone | VARCHAR(20) UNIQUE | 登录主键,唯一索引 `ix_user_phone` |
|
||||
| phone | VARCHAR(20) UNIQUE | 登录主键(注销后变 `deleted_<id>` 占位) |
|
||||
| register_channel | VARCHAR(20) | `jverify` / `sms` |
|
||||
| nickname | VARCHAR(64) NULL | 预留,**无接口可写,恒为 null** |
|
||||
| avatar_url | VARCHAR(512) NULL | 同上 |
|
||||
| nickname | VARCHAR(64) NULL | 由 `PATCH /api/v1/user/profile` 写入 |
|
||||
| avatar_url | VARCHAR(512) NULL | 相对路径(`/media/avatars/...`),由 `POST /api/v1/user/avatar` 写入 |
|
||||
| wechat_openid | VARCHAR(64) NULL UNIQUE | 微信绑定(提现用),一个微信只绑一个账号 |
|
||||
| wechat_nickname / wechat_avatar_url | VARCHAR NULL | 微信侧资料(绑定时拉取) |
|
||||
| status | VARCHAR(20) | `active` / `disabled` / `deleted`,仅 active 可登录/鉴权 |
|
||||
| created_at / last_login_at | DATETIME(tz) | |
|
||||
|
||||
`upsert_user_for_login`:phone 存在则更新 `last_login_at`,不存在则注册(注册即登录)。Alembic 实际库里另有框架表 `alembic_version`。
|
||||
`upsert_user_for_login`:phone 存在则更新 `last_login_at`,不存在则注册(注册即登录)。
|
||||
|
||||
**Alembic 迁移**:`alembic/versions/` 当前 11 个迁移 + 1 个合并迁移(`f01db5d77dac_merge_pg_jsonb_and_feedback_heads.py`,2026-05-29 创建——实习生 PG 分支和 user-profile-feedback 分支并行开发时 down_revision 都挂在 `c8d9e0f1a2b3` 上,导致多 head,合并迁移把两条链合并)。详见 [数据库迁移.md](./数据库迁移.md)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 配置与部署
|
||||
|
||||
配置见 `core/config.py`(pydantic-settings 读 `.env`)。分组:环境、`DATABASE_URL`、JWT、极光(`JG_*`)、短信(`SMS_MOCK` 默认 true)、美团(`MT_CPS_*`)、**pricebot 上游(`PRICEBOT_BASE_URL` / `PRICEBOT_REQUEST_TIMEOUT_SEC`)**、CORS。
|
||||
配置见 `core/config.py`(pydantic-settings 读 `.env`)。分组:环境、`DATABASE_URL`(默认 SQLite,生产应切 PG)、JWT、极光(`JG_*`)、短信(`SMS_MOCK` 默认 true)、美团(`MT_CPS_*`)、**pricebot 上游(`PRICEBOT_BASE_URL` / `PRICEBOT_REQUEST_TIMEOUT_SEC=30` / `PRICEBOT_COMPARE_TIMEOUT_SEC=60`)**、**媒体存储(`MEDIA_ROOT=./data/media` / `MEDIA_URL_PREFIX=/media`)**、CORS。
|
||||
|
||||
**生产部署**:systemd `shaguabijia-app-server.service`(WorkingDirectory `/opt/shaguabijia-app-server`,`EnvironmentFile=.env`,uvicorn 监听 `127.0.0.1:8770`,`--workers 1`)+ nginx 443 反代 → 8770。**无 Docker**。
|
||||
**生产部署**:systemd `shaguabijia-app-server.service`(WorkingDirectory `/opt/shaguabijia-app-server`,`EnvironmentFile=.env`,uvicorn 监听 `127.0.0.1:8770`,`--workers 1`)+ nginx 443 反代 → 8770。**无 Docker**。**PostgreSQL 16** 同机部署。
|
||||
|
||||
```bash
|
||||
# 首次 PG 初始化(新机器或新环境):
|
||||
ssh server "sudo apt install -y postgresql-16 && sudo systemctl enable --now postgresql"
|
||||
ssh server "cd /opt/shaguabijia-app-server && .venv/bin/python scripts/init_postgres.py"
|
||||
# 该脚本会建业务用户 + 建库 + 写 .env 的 DATABASE_URL + 跑 alembic upgrade head
|
||||
|
||||
# 后续日常部署:
|
||||
rsync -avz --exclude='.venv' --exclude='__pycache__' --exclude='data' --exclude='secrets/*.pem' ./ server:/opt/shaguabijia-app-server/
|
||||
scp secrets/jverify_rsa_private.pem server:/opt/shaguabijia-app-server/secrets/ # 私钥单独传,不入 git
|
||||
ssh server "cd /opt/shaguabijia-app-server && .venv/bin/alembic upgrade head && systemctl restart shaguabijia-app-server"
|
||||
```
|
||||
|
||||
完整 PG 切换流程见 [docs/postgres-migration.md](./postgres-migration.md)。
|
||||
|
||||
**生产 checklist(均为上线必查)**:
|
||||
|
||||
- [ ] `JWT_SECRET_KEY` 改为高熵随机串——默认值 `change-me` 可被伪造 token
|
||||
- [ ] `SMS_MOCK=false` 并接真实短信供应商——mock 下任意 6 位码可登录任意手机号
|
||||
- [ ] `MT_CPS_APP_KEY` / `MT_CPS_APP_SECRET` 已填(否则美团接口 502)
|
||||
- [ ] `MT_CPS_APP_KEY` / `MT_CPS_APP_SECRET` 已填(未配凭证时美团 3 端点降级返空,前端首页空白)
|
||||
- [ ] `PRICEBOT_BASE_URL` 指向真实 pricebot-backend(否则领券 502)
|
||||
- [ ] RSA 私钥就位且与极光控制台公钥**配对**,权限 600
|
||||
- [ ] **DATABASE_URL 已切 PG**(默认 SQLite 单文件并发写锁库,扩 worker 立即撞墙)
|
||||
- [ ] **psycopg3 已装**(`pip install "psycopg[binary]>=3.1"`)
|
||||
- [ ] `MEDIA_ROOT` 目录可写(头像/反馈截图落盘);建议 nginx 直接 serve 该目录绕过应用进程
|
||||
- [ ] `APP_ENV=prod`、`APP_DEBUG=false`、nginx SSL 有效、`alembic upgrade head` 已执行
|
||||
|
||||
---
|
||||
@@ -255,12 +305,15 @@ conda activate price # 首次:pip install -e .
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| logout 无服务端失效 | 靠客户端清 token;后续加 jti 黑名单表 |
|
||||
| logout 无服务端失效 | 靠客户端清 token;后续加 jti 黑名单表(注销账号也是同问题——软删后旧 token 仍能用到自然过期) |
|
||||
| 美团接口无鉴权 + sid 可覆盖 | 评估加鉴权/锁定 sid(注意首页要求未登录可见) |
|
||||
| feed 静默吞异常 | 建议给 `_fetch_topic` 加日志,避免无声失败 |
|
||||
| 领券依赖 pricebot | `coupon/step` 仅透传,真正逻辑在 pricebot-backend;前端已接通领券链路 |
|
||||
| 美团接口未配凭证降级 | 未配 `MT_CPS_APP_KEY` 时 3 端点返空(不报 502),`/feed` 跟"已配但调用失败"路径无法区分——见 [integrations/meituan](./integrations/meituan.md) |
|
||||
| 领券/比价依赖 pricebot | `coupon/step` / `intent/recognize` / `price/step` 仅透传,真正逻辑在 pricebot-backend;前端已接通领券链路,比价 food MVP 也已接通 |
|
||||
| agent 系列接口 MVP 不鉴权 | 拿不到 user_id → 无法采集"哪个用户领了/买了什么"用户级画像(商业模式核心资产)。见 [待办与技术债.md](./待办与技术债.md) P1 |
|
||||
| SMS 为 mock | 上线接真实供应商 + `SMS_MOCK=false` |
|
||||
| 短信冷却存内存 | 扩 worker 前需迁移到 Redis |
|
||||
| SQLite | 流量上来切 PostgreSQL,只改 `DATABASE_URL` |
|
||||
| `MEDIA_ROOT` 进程内 serve | 头像/反馈截图当前用 FastAPI StaticFiles,生产建议 nginx 直 serve 该目录 |
|
||||
| `init_postgres.py` 已知小 bug | 5 条小坑,见 [待办与技术债.md](./待办与技术债.md) |
|
||||
| Alembic 多 head 风险 | 跨分支并行开发要在 PR 合并前 `alembic heads` 检查只有一个;漏检会出现 "Multiple head revisions",需要 `alembic merge` 合并 |
|
||||
|
||||
> 完整接口协议(11 个)见 [`docs/api/`](./api/),以代码为准。
|
||||
> 完整接口协议见 [`docs/api/`](./api/)(38 个端点 + `/health` + `/media` 静态服务),以代码为准。
|
||||
|
||||
+39
-1
@@ -3,7 +3,7 @@
|
||||
> 用途:记"现在先简化、以后要补"的事,和暂不处理的技术债,免得忘。每条标优先级 + 触发背景。做掉的移到末尾「已解决」。
|
||||
> 范围:跨栈账本——本仓 `shaguabijia-app-server`(转发层) + `shaguabijia-app-android`(无障碍引擎移植) + `pricebot-backend`(agent 上游,不改)。
|
||||
> 大背景:把"后端当大脑、无障碍服务遥控前端操作其它 App"的比价/领券引擎,从 demo app `pricebot` 移植进正式版傻瓜比价。引擎移植采用「代码全搬、独立子包 `…shaguabijia.agent.*`、验证分步(先领券后比价)」。
|
||||
> 最后更新:2026-05-27
|
||||
> 最后更新:2026-05-31
|
||||
|
||||
---
|
||||
|
||||
@@ -44,6 +44,39 @@
|
||||
|
||||
---
|
||||
|
||||
## P2 · DB 与 Alembic 治理(2026-05-29 新增)
|
||||
|
||||
### Alembic 多 head 风险 — 团队流程问题
|
||||
|
||||
**触发背景**:2026-05-29 用 PG 跑 `alembic upgrade head` 报 "Multiple head revisions are present"。
|
||||
- 实习生的 `feat/postgres-migration-and-bootstrap-script` 分支基于 `4d444b7` (已合 PR #4 feedback) 创建,但新迁移 `ef96beb47b1e` 的 `down_revision` 挂在 `c8d9e0f1a2b3` (ad_reward),跟 PR #4 引入的 `d1e2f3a4b5c6` (feedback) 并行成两个 head
|
||||
- **根因**:跨分支并行开发,PR 合并时没人检查 alembic 迁移链是否线性
|
||||
|
||||
**临时修复**:`alembic merge -m "merge pg_jsonb and feedback heads" ef96beb47b1e d1e2f3a4b5c6` 生成合并迁移 `f01db5d77dac_merge_pg_jsonb_and_feedback_heads.py`(已合进 main)。
|
||||
|
||||
**团队规约(待写进 CONTRIBUTING)**:
|
||||
- 新建包含 alembic 迁移的 PR 前,在分支上跑 `alembic heads`,确认**只有一个 head**;有多个就先 rebase 自己的迁移文件的 `down_revision`
|
||||
- review 别人含 alembic 迁移的 PR 时,同样检查 heads 数
|
||||
- 合并 main 之后再跑一次 `alembic heads` 兜底——本次撞到就是 review 没拦住
|
||||
|
||||
### `init_postgres.py` 已知小 bug(2026-05-29 review)
|
||||
|
||||
`scripts/init_postgres.py` 是实习生为 PG 切换写的 bootstrap 脚本(建用户/库/写 .env/跑迁移),有 5 处可改进项:
|
||||
|
||||
1. **`if val:` 把空字符串当未设** → `PG_SUPER_PASS=""` 不能传(本机 trust 认证想跳过密码就只能给占位字符串)
|
||||
2. **用户自定义 `APP_DB_PASS` 含 `@:/` 会破坏 URL** → 应 `urllib.parse.quote(db_pass, safe='')`(自动生成的 `secrets.token_urlsafe` 是 URL 安全字符,但用户传值不一定)
|
||||
3. **subprocess 跑 alembic 没显式注 `DATABASE_URL`** → 父 shell 残留旧值会覆盖刚写入 .env 的新值,可能把表建到错的库
|
||||
4. **强制 TCP localhost** → macOS Homebrew(默认 trust)和 Linux apt(默认 peer)的默认认证模式都不直接走 TCP,新机首次跑大概率失败
|
||||
5. **承诺的 JSONB GIN 索引没真加** → commit message 和文档都说"PG 上可建 GIN 索引",但 `savings_record.dishes` 实际没加;要么补 `CREATE INDEX ... USING GIN (dishes)`,要么把"GIN"措辞从理由里删掉
|
||||
|
||||
短期不阻塞使用(熟手按预期参数能跑通),但累积起来会让新人踩坑。低优先级清理。
|
||||
|
||||
### `MEDIA_ROOT` 进程内 serve
|
||||
|
||||
`/media` 路径当前由 FastAPI StaticFiles 挂载 serve(头像/反馈截图)。生产建议改 nginx 直 serve `MEDIA_ROOT` 目录,绕过应用进程减少压力。一行 `location /media/ { alias /opt/shaguabijia-app-server/data/media/; }` 即可。
|
||||
|
||||
---
|
||||
|
||||
## 阶段 1 领券落地后的遗留(待清理,不阻塞跑通)
|
||||
|
||||
- **FloatingButton 死代码**:Running 面板已换成 ComposeView 承载 `CouponProgressPanel`(=复用 AgentFloat)。旧原生面板字段(`line1~4View`/`elapsedView`/`progressBarView`/`historyContainerView` 等)+ `updateStatus(StepStatus)` 里保留的那段更新逻辑 + `appendActionLine`/`markCurrentActionDone`/`markPlatformDone`/`addCancelButton` 全部 no-op,待删。
|
||||
@@ -61,3 +94,8 @@
|
||||
- ✅ **无障碍身份统一傻瓜比价**:服务名/图标继承 app;`accessibility_service_description`/前台通知/`GuideCopy`/引导 Activity 里的 "PriceBot" 全改「傻瓜比价」;app 图标换成完整 `logo1`(adaptive 居中+黄边,不裁切)。
|
||||
- ✅ **GuideOverlayService 组件名 bug**:批量 sed 改包名误把无障碍服务 ComponentName 的 packageName 改成 `…shaguabijia.agent`(应是 applicationId `…shaguabijia`)→ 无障碍开启检测永远 false;改用 `PermissionHelper.isAccessibilityEnabled` 动态构造。
|
||||
- ✅ **BuildConfig 包名**:`com.pricebot.app.BuildConfig` import 改本 app 包名。
|
||||
- ✅ **SQLite → PostgreSQL 切换**(2026-05-29,PR #6 + alembic 合并迁移):新增 `scripts/init_postgres.py` 一键脚本、`psycopg[binary]>=3.1` 依赖、`savings_record.dishes` 列改 JSONB、连接池参数(`pool_size=10/overflow=20/recycle=3600`)。SQLite 仍可作开发兜底(只改 `DATABASE_URL`)。详见 [postgres-migration.md](./postgres-migration.md)。
|
||||
- ✅ **外卖比价透传 2 端点**(2026-05-27,`feat(compare)`):`/api/v1/intent/recognize` + `/api/v1/price/step` 在 `compare.py`,纯 body 透传到 pricebot-backend,`PRICEBOT_COMPARE_TIMEOUT_SEC=60` 独立超时。电商 2 个待接(`compare.py` 加两行即可)。
|
||||
- ✅ **用户资料 + 帮助反馈接口**(2026-05-28,PR #4):`PATCH /api/v1/user/profile` 改昵称、`POST /api/v1/user/avatar` 上传头像(魔数嗅探)、`DELETE /api/v1/user` 注销账号(软删+匿名化)、`POST /api/v1/feedback` 提交反馈(含截图);新建 `feedback` 表 + `core/media.py` + `/media` 静态服务。
|
||||
- ✅ **美团 3 端点未配凭证降级**(2026-05-28,`feat(meituan)`):未配 `MT_CPS_APP_KEY` 时 `coupons`/`feed`/`referral-link` 直接返空(不再 502),解决新开发机首屏炸的问题。**坏副作用**:`/feed` 跟"已配但调用失败"路径在响应上无法区分(都是 200 + 空 items),靠 `/health` 的 `mt_cps_configured` 字段区分。
|
||||
- ✅ **看广告 4 态 CTA + 本轮冷却**(2026-05-29,PR #5):`reward-status` 加 `round_count` + `cooldown_until` 字段,客户端 CTA 4 态(Normal/Loading/Capped/CoolingDown)+ 弹窗 limit note("本轮看完"/"今日看完")全由后端权威派生,跨设备/重装/杀进程都一致。**冷却仅 UX**,发奖不受影响。
|
||||
|
||||
+76
-20
@@ -2,6 +2,11 @@
|
||||
|
||||
> 本项目用 **Alembic** 管理数据库表结构。所有"建表/改表"都写成 `alembic/versions/` 下的迁移脚本,
|
||||
> 数据库的真实结构 = 把这些迁移按顺序跑一遍的结果。**不要手动改库结构**,一律走迁移。
|
||||
>
|
||||
> **相关文档**:
|
||||
> - 这份文档讲 **alembic 操作**(如何建表/升级/新增迁移/多 head 排查)
|
||||
> - [postgres-migration.md](./postgres-migration.md) 讲 **从 SQLite 切到 PostgreSQL** 的完整步骤(本机安装/建库/`scripts/init_postgres.py` 用法)
|
||||
> - 配合 [后端技术实现.md §10](./后端技术实现.md) 看待办与已知问题
|
||||
|
||||
---
|
||||
|
||||
@@ -12,23 +17,42 @@ alembic upgrade head
|
||||
```
|
||||
它会把数据库结构升到最新(幂等,已是最新则什么都不做)。
|
||||
|
||||
**新 PG 环境从零开始**:跑 `python scripts/init_postgres.py`,它会建用户 + 建库 + 写 .env + 跑迁移。详见 [postgres-migration.md](./postgres-migration.md)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 首次初始化(clone 后)
|
||||
|
||||
### 1.1 用 PostgreSQL(推荐,与生产一致)
|
||||
前置:本机已装 PostgreSQL 16 + 知道 postgres 超级用户密码。
|
||||
|
||||
```bash
|
||||
# (1) 装依赖(在你的虚拟环境里)
|
||||
# (1) 装依赖(在你的虚拟环境里, 含 psycopg)
|
||||
pip install -e .
|
||||
|
||||
# (2) 配 .env(从模板复制,至少填 JWT_SECRET_KEY)
|
||||
cp .env.example .env
|
||||
# (2) 一键建用户 + 建库 + 写 .env + 跑迁移
|
||||
python scripts/init_postgres.py
|
||||
# 按提示输入 PG 超级用户密码即可。脚本会:
|
||||
# - 自动建业务用户 shaguabijia_app + 业务库 shaguabijia
|
||||
# - 自动生成业务用户强密码,写入 .env 的 DATABASE_URL
|
||||
# - 跑完 alembic upgrade head
|
||||
|
||||
# (3) SQLite 默认库需要 data/ 目录存在
|
||||
# (3) 启动
|
||||
./run.sh
|
||||
```
|
||||
|
||||
可用环境变量提前指定(免交互):
|
||||
```bash
|
||||
PG_SUPER_PASS=xxx APP_DB_PASS=yyy python scripts/init_postgres.py
|
||||
```
|
||||
脚本幂等,重复跑会重置业务用户密码、跳过已存在的库。
|
||||
|
||||
### 1.2 用 SQLite(本地开发临时用,不接生产链路时)
|
||||
```bash
|
||||
pip install -e .
|
||||
cp .env.example .env # 不动 DATABASE_URL, 默认 sqlite:///./data/app.db
|
||||
mkdir -p data
|
||||
|
||||
# (4) 跑迁移建表 —— 关键
|
||||
alembic upgrade head
|
||||
|
||||
# (5) 启动(run.sh 会自动重跑 3+4,所以平时直接 ./run.sh 也行)
|
||||
./run.sh
|
||||
```
|
||||
> 也可以直接 `bash scripts/migrate.sh` 只做迁移、不启服务(部署/CI 用)。
|
||||
@@ -68,21 +92,53 @@ alembic upgrade head
|
||||
- 生成后**手动把文件名前缀去掉**(内部 id 别动);或
|
||||
- 在 `alembic.ini` 配 `file_template = %%(slug)s`(注意 ini 里 `%` 要写成 `%%`)让以后直接生成无前缀文件名(缺点:同名 slug 会冲突,描述写具体点)。
|
||||
|
||||
## 6. 当前迁移链(9 条)
|
||||
## 6. 当前迁移链(12 条,含合并迁移)
|
||||
```
|
||||
init_user_table ← 起点(down_revision=None)
|
||||
→ welfare_tables_coin_account_coin_txn 金币账户/金币流水/签到/任务表
|
||||
→ cash_transaction_table 现金流水表
|
||||
→ savings_record_table 省钱明细表
|
||||
→ withdraw_order_and_user_openid 提现单表 + user.wechat_openid
|
||||
→ user_wechat_nickname_avatar user 微信昵称/头像字段
|
||||
→ unique_wechat_openid user.wechat_openid 唯一约束
|
||||
→ savings_shop_dishes 省钱明细 shop_name + dishes
|
||||
→ ad_reward_record 看广告发奖记录表 (head,最新)
|
||||
init_user_table ← 起点(down_revision=None)
|
||||
→ welfare_tables_coin_account_coin_txn 金币账户/金币流水/签到/任务表
|
||||
→ cash_transaction_table 现金流水表
|
||||
→ savings_record_table 省钱明细表
|
||||
→ withdraw_order_and_user_openid 提现单表 + user.wechat_openid
|
||||
→ user_wechat_nickname_avatar user 微信昵称/头像字段
|
||||
→ unique_wechat_openid user.wechat_openid 唯一约束
|
||||
→ savings_shop_dishes 省钱明细 shop_name + dishes
|
||||
→ ad_reward_record 看广告发奖记录表
|
||||
→ (此处分叉,两条并行 head)
|
||||
├→ feedback_table 帮助与反馈表(PR #4)
|
||||
└→ ef96beb47b1e_convert_savings_record_dishes_from_json dishes 列改 JSONB(PR #6)
|
||||
→ f01db5d77dac_merge_pg_jsonb_and_feedback_heads (head,最新) 合并两条分叉
|
||||
```
|
||||
> 顺序以 `alembic history` 输出为准(按 `down_revision` 链,不是文件名字母序)。
|
||||
|
||||
## 7. 踩坑
|
||||
- **SQLite 找不到目录**:报 `unable to open database file` → 先 `mkdir -p data`。
|
||||
- **多个 head**:`alembic heads` 出现 2 个 → 有人并行各加了一条迁移分叉,需 `alembic merge -m "merge" <head1> <head2>` 合并。
|
||||
|
||||
### SQLite 找不到目录
|
||||
报 `unable to open database file` → 先 `mkdir -p data`。
|
||||
|
||||
### 多 head 分叉(2026-05-29 撞过)
|
||||
|
||||
`alembic upgrade head` 报:
|
||||
```
|
||||
Multiple head revisions are present for given argument 'head';
|
||||
please specify a specific target revision, '<branchname>@head'...
|
||||
```
|
||||
|
||||
**原因**:跨分支并行开发,两条分支都加了新迁移且 `down_revision` 都指向同一个 commit,合并到 main 后形成两条平行链。本次实例:
|
||||
- PR #4 (feedback) 加了 `d1e2f3a4b5c6`,`down_revision='c8d9e0f1a2b3'` (ad_reward)
|
||||
- PR #6 (PG/JSONB) 加了 `ef96beb47b1e`,`down_revision='c8d9e0f1a2b3'`(也是 ad_reward) ← 跟 #4 同源,撞车
|
||||
- 两个 PR 都合进 main 后,`alembic heads` 出现两个
|
||||
|
||||
**修复**:
|
||||
```bash
|
||||
alembic heads # 列出冲突的两个 head id
|
||||
alembic merge -m "merge <description>" <head1> <head2>
|
||||
# 生成一个空迁移文件,把两个 head 合成新的单一 head
|
||||
git add alembic/versions/<new_merge_file>.py
|
||||
# commit + push, PR review 后合并到 main
|
||||
```
|
||||
|
||||
**预防(团队规约)**:
|
||||
- 写迁移的 PR 在提交前跑 `alembic heads`,确认本分支只有一个 head
|
||||
- review 别人含迁移的 PR 时,看 `down_revision` 是否还是当前 main 的最新——如果上游已经被别人合了新迁移,需要 rebase 修改 `down_revision` 指过去
|
||||
- main 拉新代码后再跑一次 `alembic heads` 兜底
|
||||
- **生产库**:不要随意 `downgrade`;新迁移先在本地/测试库验证过再上。生产 `DATABASE_URL` 由部署侧配置,不在本仓库 `.env`。
|
||||
|
||||
+6
-2
@@ -8,7 +8,8 @@
|
||||
- **发奖只走服务端**:激励视频播完,穿山甲服务器 S2S 回调后端 `/api/v1/ad/pangle-callback` 发金币。
|
||||
客户端**不发奖**,`onRewardArrived` 只触发"去后端刷余额"。客户端被破解也刷不到钱。
|
||||
- 后端发奖**幂等**(按 `trans_id` 去重)+ **每日上限**(`DAILY_AD_REWARD_LIMIT`,按北京时间)。
|
||||
- 关键常量:`app/core/rewards.py` → `AD_REWARD_COIN=100`、`DAILY_AD_REWARD_LIMIT=10`(**占位值,上线前按 eCPM 实测收益重定**)。
|
||||
- 关键常量:`app/core/rewards.py` → `AD_REWARD_COIN=100`、`DAILY_AD_REWARD_LIMIT=10`(**占位值,上线前按 eCPM 实测收益重定**)、`VIDEO_ROUND_REQUIRED_COUNT=3`、`VIDEO_ROUND_COOLDOWN_SECONDS=600`(本轮 3 次后冷却 10 分钟)。
|
||||
- **4 态 CTA**(2026-05-29 PR #5):客户端任务行按钮在 Normal / Loading / Capped / CoolingDown 之间切,**全由后端 `reward-status` 的 `round_count` + `cooldown_until` 派生**(权威源),跨设备/重装/杀进程一致。详见 [api/ad-reward-status](./api/ad-reward-status.md)。
|
||||
|
||||
---
|
||||
|
||||
@@ -72,7 +73,10 @@
|
||||
|
||||
- [ ] 真机看完一条**真实**激励视频 → 穿山甲 S2S 回调 → 后端发奖 → 客户端余额真到账
|
||||
- [ ] **幂等**:同一 `trans_id` 不重复发
|
||||
- [ ] **每日上限**:超过 `DAILY_AD_REWARD_LIMIT` 返回 capped、不发币、按钮显示「已达上限」
|
||||
- [ ] **每日上限**:超过 `DAILY_AD_REWARD_LIMIT` 返回 capped、不发币、按钮显示「已达上限」(Capped 态)
|
||||
- [ ] **本轮冷却**(PR #5):连看 3 张 → 按钮变 CoolingDown 显 MM:SS 倒计时不可点 → 10 分钟后自动恢复 Normal
|
||||
- [ ] **弹窗 limit note**:`round_count==0 && cooldown_until!=null` → "本轮视频已看完,10分钟后再来";`used_today>=daily_limit` → "今日视频已到限额,明天再来"
|
||||
- [ ] **跨设备/杀进程一致**:在手机 A 看完 3 张进入冷却 → 手机 B(同账号)登录后 reward-status 也返回相同的 `cooldown_until`(权威源在后端,客户端不存本地)
|
||||
- [ ] 中途退出广告 → 客户端提示「未看完视频,本次没有奖励哦」,且不发奖
|
||||
- [ ] 收益明细显示「看广告奖励」(biz_type=`ad_reward`)
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ dependencies = [
|
||||
"sqlalchemy>=2.0.35",
|
||||
"alembic>=1.13.3",
|
||||
|
||||
# PostgreSQL 驱动 (psycopg3, SQLAlchemy 2.0 时代默认, 不要再装 psycopg2)
|
||||
"psycopg[binary]>=3.1",
|
||||
|
||||
# JWT 签名 / 校验
|
||||
"pyjwt[crypto]>=2.9.0",
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""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())
|
||||
+83
-1
@@ -9,8 +9,15 @@ from sqlalchemy import select
|
||||
|
||||
from app.core.config import settings
|
||||
from app.integrations import pangle
|
||||
from app.core.rewards import AD_REWARD_COIN, DAILY_AD_REWARD_LIMIT, MAX_AD_REWARD_COIN
|
||||
from app.core.rewards import (
|
||||
AD_REWARD_COIN,
|
||||
DAILY_AD_REWARD_LIMIT,
|
||||
MAX_AD_REWARD_COIN,
|
||||
VIDEO_ROUND_COOLDOWN_SECONDS,
|
||||
VIDEO_ROUND_REQUIRED_COUNT,
|
||||
)
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.ad_reward import AdRewardRecord
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
@@ -148,6 +155,81 @@ def test_reward_status_requires_auth(client) -> None:
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_reward_status_initial_round_state(client) -> None:
|
||||
"""新用户没看广告 → round_count=0, cooldown_until=None。"""
|
||||
phone = "13800003101"
|
||||
token = _login(client, phone)
|
||||
st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
|
||||
assert st["used_today"] == 0
|
||||
assert st["round_count"] == 0
|
||||
assert st["cooldown_until"] is None
|
||||
|
||||
|
||||
def test_reward_status_mid_round(client) -> None:
|
||||
"""看 1 次(未到一轮)→ round_count=1, cooldown_until 仍 None。"""
|
||||
phone = "13800003102"
|
||||
token = _login(client, phone)
|
||||
uid = _user_id(phone)
|
||||
_callback(client, _signed(uid, "trans_mid_1"))
|
||||
st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
|
||||
assert st["used_today"] == 1
|
||||
assert st["round_count"] == 1
|
||||
assert st["cooldown_until"] is None
|
||||
|
||||
|
||||
def test_reward_status_round_complete_enters_cooldown(client) -> None:
|
||||
"""刚看完一轮 N 次 → round_count=0(下一轮起点) + cooldown_until 是未来 ~10 分钟。"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
phone = "13800003103"
|
||||
token = _login(client, phone)
|
||||
uid = _user_id(phone)
|
||||
for i in range(VIDEO_ROUND_REQUIRED_COUNT):
|
||||
_callback(client, _signed(uid, f"trans_rc_{i}"))
|
||||
|
||||
st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
|
||||
assert st["used_today"] == VIDEO_ROUND_REQUIRED_COUNT
|
||||
assert st["round_count"] == 0
|
||||
assert st["cooldown_until"] is not None
|
||||
cd = datetime.fromisoformat(st["cooldown_until"].replace("Z", "+00:00"))
|
||||
if cd.tzinfo is None:
|
||||
cd = cd.replace(tzinfo=timezone.utc)
|
||||
now = datetime.now(timezone.utc)
|
||||
delta = (cd - now).total_seconds()
|
||||
# 配置 600s,允许 ±30s 容差(本机 / CI 慢 IO)
|
||||
assert 0 < delta <= VIDEO_ROUND_COOLDOWN_SECONDS + 30
|
||||
|
||||
|
||||
def test_reward_status_cooldown_expired(client) -> None:
|
||||
"""看完一轮后冷却已过(手动改 created_at 到 11 分钟前)→ cooldown_until 回到 None。"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from sqlalchemy import select, update
|
||||
|
||||
phone = "13800003104"
|
||||
token = _login(client, phone)
|
||||
uid = _user_id(phone)
|
||||
for i in range(VIDEO_ROUND_REQUIRED_COUNT):
|
||||
_callback(client, _signed(uid, f"trans_exp_{i}"))
|
||||
|
||||
# 把当日所有 granted 记录的 created_at 推到 11 分钟前(覆盖冷却末尾那条)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
eleven_min_ago = datetime.now(timezone.utc) - timedelta(seconds=VIDEO_ROUND_COOLDOWN_SECONDS + 60)
|
||||
db.execute(
|
||||
update(AdRewardRecord)
|
||||
.where(AdRewardRecord.user_id == uid)
|
||||
.values(created_at=eleven_min_ago)
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
|
||||
assert st["used_today"] == VIDEO_ROUND_REQUIRED_COUNT
|
||||
assert st["round_count"] == 0
|
||||
assert st["cooldown_until"] is None
|
||||
|
||||
|
||||
def test_callback_disabled_returns_503(client, monkeypatch) -> None:
|
||||
"""未配置回调(开关关)时 → 503。"""
|
||||
monkeypatch.setattr(settings, "PANGLE_CALLBACK_ENABLED", False)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""记账测试:比价后下单上报 → 写 savings_record(source='compare')。
|
||||
|
||||
覆盖:上报落库 + 门店/菜品/原价随之入库、(user,client_event_id) 幂等、
|
||||
真实(compare)记录优先于 demo seeder、省额 = 源平台原价 − 实付。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _login(client, phone: str) -> str:
|
||||
client.post("/api/v1/auth/sms/send", json={"phone": phone})
|
||||
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def _auth(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _report_body(**over) -> dict:
|
||||
body = {
|
||||
"client_event_id": "evt-1",
|
||||
"platform": "淘宝闪购",
|
||||
"platform_package": "com.taobao.taobao",
|
||||
"pay_channel": "alipay",
|
||||
"compared_price_cents": 3300,
|
||||
"paid_amount_cents": 3300,
|
||||
"device_id": "device_test",
|
||||
"shop_name": "肯德基宅急送(天北路店)",
|
||||
"dishes": ["史迪奇玩具套餐 x1"],
|
||||
"original_price_cents": 3680,
|
||||
"source_platform_name": "美团",
|
||||
"source_deeplink": "https://example.com/reorder",
|
||||
}
|
||||
body.update(over)
|
||||
return body
|
||||
|
||||
|
||||
def test_order_report_creates_real_savings(client) -> None:
|
||||
"""上报一笔 → savings 统计/明细只反映这笔真实数据,含门店/菜品/原价。"""
|
||||
token = _login(client, "13800002001")
|
||||
r = client.post("/api/v1/order/report", json=_report_body(), headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
out = r.json()
|
||||
assert out["duplicated"] is False
|
||||
assert out["paid_amount_cents"] == 3300
|
||||
assert out["platform"] == "淘宝闪购"
|
||||
|
||||
# summary:真实优先,只算这一笔(demo 不再混入)
|
||||
s = client.get("/api/v1/savings/summary", headers=_auth(token)).json()
|
||||
assert s["order_count"] == 1
|
||||
assert s["total_saved_cents"] == 380 # 3680 − 3300
|
||||
assert s["avg_saved_cents"] == 380
|
||||
|
||||
# records:门店/菜品/原价/支付渠道/源平台都在
|
||||
page = client.get("/api/v1/savings/records", headers=_auth(token)).json()
|
||||
assert len(page["items"]) == 1
|
||||
it = page["items"][0]
|
||||
assert it["shop_name"] == "肯德基宅急送(天北路店)"
|
||||
assert it["dishes"] == ["史迪奇玩具套餐 x1"]
|
||||
assert it["order_amount_cents"] == 3300
|
||||
assert it["saved_amount_cents"] == 380
|
||||
assert it["original_price_cents"] == 3680
|
||||
assert it["platform"] == "淘宝闪购"
|
||||
assert it["pay_channel"] == "alipay"
|
||||
assert it["source_platform_name"] == "美团"
|
||||
|
||||
|
||||
def test_order_report_idempotent(client) -> None:
|
||||
"""同 (user, client_event_id) 重复上报:第二次 duplicated=True,不新增。"""
|
||||
token = _login(client, "13800002002")
|
||||
body = _report_body(client_event_id="evt-dup")
|
||||
r1 = client.post("/api/v1/order/report", json=body, headers=_auth(token))
|
||||
assert r1.json()["duplicated"] is False
|
||||
r2 = client.post("/api/v1/order/report", json=body, headers=_auth(token))
|
||||
assert r2.json()["duplicated"] is True
|
||||
s = client.get("/api/v1/savings/summary", headers=_auth(token)).json()
|
||||
assert s["order_count"] == 1
|
||||
|
||||
|
||||
def test_real_overrides_demo_seed(client) -> None:
|
||||
"""先触发 demo seeder(多笔),再上报一笔真实 → 统计切换为只算真实那笔。"""
|
||||
token = _login(client, "13800002003")
|
||||
demo = client.get("/api/v1/savings/summary", headers=_auth(token)).json()
|
||||
assert demo["order_count"] > 1 # demo 是多笔
|
||||
|
||||
client.post(
|
||||
"/api/v1/order/report",
|
||||
json=_report_body(
|
||||
client_event_id="evt-real", original_price_cents=5000, paid_amount_cents=4200
|
||||
),
|
||||
headers=_auth(token),
|
||||
)
|
||||
s = client.get("/api/v1/savings/summary", headers=_auth(token)).json()
|
||||
assert s["order_count"] == 1 # demo 被忽略
|
||||
assert s["total_saved_cents"] == 800 # 5000 − 4200
|
||||
|
||||
page = client.get("/api/v1/savings/records", headers=_auth(token)).json()
|
||||
assert len(page["items"]) == 1
|
||||
assert page["items"][0]["saved_amount_cents"] == 800
|
||||
|
||||
|
||||
def test_order_report_requires_auth(client) -> None:
|
||||
r = client.post("/api/v1/order/report", json=_report_body())
|
||||
assert r.status_code == 401
|
||||
Reference in New Issue
Block a user