feat(compare): 比价记录落库 + 战绩里程碑(真发金币)

- comparison_record 表 + 上报(幂等 user+trace)/列表/详情 接口;服务端派生
  best/saved/is_source_best/status + information(done 帧文案/失败具体原因)
- 战绩里程碑:comparison_milestone_claim 表 + 进度/领取接口,按成功比价次数
  解锁,复用 grant_coins 真发金币(RECORD_MILESTONES 120/180/300/500/800/1200)
- 迁移 record/information/milestone 三个,单 head;rewards.py 集中档位配置
- 测试 test_compare_record(8)+test_compare_milestone(7) 全绿;docs/api 12a-12e + 索引

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
OuYingJun1024
2026-05-31 19:56:47 +08:00
parent eb5f0dc460
commit 15dcfa604b
23 changed files with 1400 additions and 4 deletions
@@ -0,0 +1,41 @@
"""comparison_milestone_claim table (比价战绩里程碑领取记录)
Revision ID: d4e5f6a7b8c9
Revises: c3d4e5f6a7b8
Create Date: 2026-05-31 18:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd4e5f6a7b8c9'
down_revision: Union[str, Sequence[str], None] = 'c3d4e5f6a7b8'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'comparison_milestone_claim',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('milestone', sa.Integer(), nullable=False),
sa.Column('coin_awarded', sa.Integer(), nullable=False),
sa.Column('claimed_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'milestone', name='uq_compare_milestone_user'),
)
with op.batch_alter_table('comparison_milestone_claim', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_comparison_milestone_claim_user_id'), ['user_id'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('comparison_milestone_claim', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_comparison_milestone_claim_user_id'))
op.drop_table('comparison_milestone_claim')
@@ -0,0 +1,28 @@
"""comparison_record.information (done 帧文案/失败原因)
Revision ID: c3d4e5f6a7b8
Revises: b2c3d4e5f6a7
Create Date: 2026-05-31 18:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'c3d4e5f6a7b8'
down_revision: Union[str, Sequence[str], None] = 'b2c3d4e5f6a7'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('comparison_record', schema=None) as batch_op:
batch_op.add_column(sa.Column('information', sa.String(length=256), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('comparison_record', schema=None) as batch_op:
batch_op.drop_column('information')
@@ -0,0 +1,64 @@
"""comparison_record table (比价记录:每次比价完整明细,「我的比价记录」数据源)
Revision ID: b2c3d4e5f6a7
Revises: a1b2c3d4e5f6
Create Date: 2026-05-31 15:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b2c3d4e5f6a7'
down_revision: Union[str, Sequence[str], None] = 'a1b2c3d4e5f6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'comparison_record',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('device_id', sa.String(length=64), nullable=True),
sa.Column('business_type', sa.String(length=16), nullable=False),
sa.Column('trace_id', sa.String(length=64), nullable=False),
sa.Column('source_platform_id', sa.String(length=32), nullable=True),
sa.Column('source_platform_name', sa.String(length=32), nullable=True),
sa.Column('source_package', sa.String(length=128), nullable=True),
sa.Column('source_price_cents', sa.Integer(), nullable=True),
sa.Column('best_platform_id', sa.String(length=32), nullable=True),
sa.Column('best_platform_name', sa.String(length=32), nullable=True),
sa.Column('best_price_cents', sa.Integer(), nullable=True),
sa.Column('saved_amount_cents', sa.Integer(), nullable=True),
sa.Column('is_source_best', sa.Boolean(), nullable=True),
sa.Column('store_name', sa.String(length=128), nullable=True),
sa.Column('total_dish_count', sa.Integer(), nullable=True),
sa.Column('skipped_dish_count', sa.Integer(), nullable=True),
sa.Column('status', sa.String(length=16), nullable=False),
# PG 上为 JSONB,其它(SQLite)为 JSON——与模型层 with_variant 对齐
sa.Column('items', sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), 'postgresql'), nullable=False),
sa.Column('comparison_results', sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), 'postgresql'), nullable=False),
sa.Column('skipped_dish_names', sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), 'postgresql'), nullable=False),
sa.Column('raw_payload', sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), 'postgresql'), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'trace_id', name='uq_comparison_user_trace'),
)
with op.batch_alter_table('comparison_record', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_comparison_record_user_id'), ['user_id'], unique=False)
batch_op.create_index(batch_op.f('ix_comparison_record_business_type'), ['business_type'], unique=False)
batch_op.create_index(batch_op.f('ix_comparison_record_created_at'), ['created_at'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('comparison_record', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_comparison_record_created_at'))
batch_op.drop_index(batch_op.f('ix_comparison_record_business_type'))
batch_op.drop_index(batch_op.f('ix_comparison_record_user_id'))
op.drop_table('comparison_record')
+76
View File
@@ -0,0 +1,76 @@
"""比价战绩里程碑 endpoint(福利页「记录比价战绩」)。
路由前缀 `/api/v1/compare`:
GET /milestones 进度与各档领取状态
POST /milestones/{milestone}/claim 领取某档奖励
**均需鉴权**。解锁进度 = 该用户 status='success' 的 comparison_record 条数;每档领一次。
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, status
from app.api.deps import CurrentUser, DbSession
from app.repositories import comparison_milestone as crud_milestone
from app.schemas.compare_record import (
MilestoneClaimResultOut,
MilestoneStateOut,
MilestoneStatusOut,
)
logger = logging.getLogger("shagua.compare_milestone")
router = APIRouter(prefix="/api/v1/compare", tags=["compare-milestone"])
@router.get(
"/milestones",
response_model=MilestoneStatusOut,
summary="比价战绩里程碑进度",
)
def get_milestones(user: CurrentUser, db: DbSession) -> MilestoneStatusOut:
st = crud_milestone.get_status(db, user.id)
return MilestoneStatusOut(
success_count=st.success_count,
claimable_count=st.claimable_count,
milestones=[
MilestoneStateOut(milestone=m.milestone, coin=m.coin, state=m.state)
for m in st.milestones
],
)
@router.post(
"/milestones/{milestone}/claim",
response_model=MilestoneClaimResultOut,
summary="领取比价战绩里程碑奖励",
)
def claim_milestone(
milestone: int, user: CurrentUser, db: DbSession
) -> MilestoneClaimResultOut:
try:
coin, balance = crud_milestone.claim(db, user.id, milestone)
except crud_milestone.UnknownMilestoneError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="unknown milestone"
) from e
except crud_milestone.MilestoneLockedError as e:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="milestone locked"
) from e
except crud_milestone.AlreadyClaimedError as e:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="milestone already claimed"
) from e
logger.info(
"compare milestone claimed user_id=%d milestone=%d coin=%d",
user.id,
milestone,
coin,
)
return MilestoneClaimResultOut(
milestone=milestone, coin_awarded=coin, coin_balance=balance
)
+86
View File
@@ -0,0 +1,86 @@
"""比价记录 endpoint(「我的比价记录」数据源)。
路由前缀 `/api/v1/compare`:
POST /record 上报一次比价结果(幂等:同 user+trace_id 覆盖)
GET /records 比价记录列表(游标分页)
GET /records/{id} 单条详情(含 raw_payload 全量)
**均需鉴权**(CurrentUser)——与同文件无关的不鉴权透传 `compare.py` 分开:那个是
转发壳(MVP 不鉴权),这里是按用户维度落库的业务接口,必须有 user_id。
注:本轮只做 server 端,客户端(android 仓)在 done 帧后调 POST /record 上报的改动
另起一轮(见 app-server docs/待办与技术债.md P1)。
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Query, status
from app.api.deps import CurrentUser, DbSession
from app.repositories import comparison as crud_compare
from app.schemas.compare_record import (
ComparisonRecordCreatedOut,
ComparisonRecordDetailOut,
ComparisonRecordIn,
ComparisonRecordPage,
ComparisonRecordOut,
)
logger = logging.getLogger("shagua.compare_record")
router = APIRouter(prefix="/api/v1/compare", tags=["compare-record"])
@router.post(
"/record",
response_model=ComparisonRecordCreatedOut,
summary="上报一次比价结果(幂等)",
)
def report_record(
payload: ComparisonRecordIn, user: CurrentUser, db: DbSession
) -> ComparisonRecordCreatedOut:
rec = crud_compare.upsert_record(db, user_id=user.id, payload=payload)
logger.info(
"compare record user=%s trace=%s biz=%s status=%s saved=%s",
user.id,
rec.trace_id,
rec.business_type,
rec.status,
rec.saved_amount_cents,
)
return ComparisonRecordCreatedOut(id=rec.id)
@router.get(
"/records",
response_model=ComparisonRecordPage,
summary="比价记录列表(游标分页)",
)
def list_records(
user: CurrentUser,
db: DbSession,
limit: int = Query(20, ge=1, le=100),
cursor: int | None = Query(None, description="上一页末条 id"),
) -> ComparisonRecordPage:
items, next_cursor = crud_compare.list_records(
db, user.id, limit=limit, cursor=cursor
)
return ComparisonRecordPage(
items=[ComparisonRecordOut.model_validate(it) for it in items],
next_cursor=next_cursor,
)
@router.get(
"/records/{record_id}",
response_model=ComparisonRecordDetailOut,
summary="比价记录详情(含 raw_payload)",
)
def get_record(
record_id: int, user: CurrentUser, db: DbSession
) -> ComparisonRecordDetailOut:
rec = crud_compare.get_record(db, user.id, record_id)
if rec is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="record not found")
return ComparisonRecordDetailOut.model_validate(rec)
+13
View File
@@ -58,6 +58,19 @@ TASK_REWARDS: dict[str, int] = {
}
# ===== 比价战绩里程碑(累计成功比价 N 次,逐档解锁领金币)=====
# 第 1→6 次的金币奖励(1-based:第 N 次比价解锁第 N 档)。值沿用客户端原型档位。
# 数据源是 comparison_record 里 status='success' 的条数;每档领一次,
# comparison_milestone_claim 去重(仿一次性任务)。要调档位/金额直接改这里。
RECORD_MILESTONES: tuple[int, ...] = (120, 180, 300, 500, 800, 1200)
RECORD_MILESTONE_COUNT: int = len(RECORD_MILESTONES)
def record_milestone_reward(milestone: int) -> int:
"""第 milestone 档(1..RECORD_MILESTONE_COUNT)的金币。越界抛 IndexError。"""
return RECORD_MILESTONES[milestone - 1]
# ===== 看激励视频发金币(穿山甲 S2S 服务端回调发奖)=====
# 看完一个激励视频发的金币(100 金币 ≈¥0.01,汇率 10000 金币=1 元)。
# 作用:① 回调缺/坏 reward_amount 时的回退值;② 客户端进度接口展示的"单次预告金币";
+4
View File
@@ -17,6 +17,8 @@ from fastapi.staticfiles import StaticFiles
from app.api.v1.ad import router as ad_router
from app.api.v1.auth import router as auth_router
from app.api.v1.compare import router as compare_router
from app.api.v1.compare_milestone import router as compare_milestone_router
from app.api.v1.compare_record import router as compare_record_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
@@ -74,6 +76,8 @@ app.include_router(user_router)
app.include_router(feedback_router)
app.include_router(coupon_router)
app.include_router(compare_router)
app.include_router(compare_record_router)
app.include_router(compare_milestone_router)
app.include_router(meituan_router)
app.include_router(wallet_router)
app.include_router(signin_router)
+2
View File
@@ -1,6 +1,8 @@
"""所有 ORM model 必须在这里 import 一次,Alembic / metadata 才能扫到。"""
from app.models.ad_ecpm import AdEcpmRecord # noqa: F401
from app.models.ad_reward import AdRewardRecord # noqa: F401
from app.models.comparison import ComparisonRecord # noqa: F401
from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401
from app.models.feedback import Feedback # noqa: F401
from app.models.savings import SavingsRecord # noqa: F401
from app.models.signin import SigninRecord # noqa: F401
+101
View File
@@ -0,0 +1,101 @@
"""比价记录表。
每完成一次比价(外卖/电商/领券),客户端在 done 帧后用带 JWT 的通道上报一条,落这里。
是未来「我的比价记录」页的数据源,也沉淀用户级行为画像(哪个用户在哪两家之间比了什么)。
与 savings_record 的区别:savings_record 是「省了多少钱」的视角(只有省到才有意义,当前由
demo seeder 灌),本表是「每一次比价的完整明细」——不省钱、甚至失败的比价也照记一条。
两表独立,互不影响。
「越详细越好」的落地:结构化列给查询/排序/聚合用,raw_payload(JSONB)把客户端上报的
原始 calibration + done.params 原样存一份,未来前端要展示什么都能拿到、不丢信息。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import (
JSON,
Boolean,
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
# PG 上用 JSONB(可建 GIN 索引),SQLite(本地/测试)退化为通用 JSON——
# 否则 SQLite 无 JSONB,Base.metadata.create_all 编译报错(同 savings_record.dishes)。
_JSON = JSON().with_variant(JSONB(), "postgresql")
class ComparisonRecord(Base):
__tablename__ = "comparison_record"
__table_args__ = (
# 同一用户同一次比价(trace_id)只存一条:客户端重试/误点重复上报时幂等覆盖。
UniqueConstraint("user_id", "trace_id", name="uq_comparison_user_trace"),
)
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
)
# 仍记录设备号(同一用户多设备的行为区分 / 与不鉴权期 device_id 数据对账)
device_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 业务类型:food(外卖,当前唯一接通)/ ecom(电商)/ coupon(领券)。预留扩展。
business_type: Mapped[str] = mapped_column(
String(16), nullable=False, default="food", index=True
)
# pricebot 侧 trace_id:关联调试落盘 + 幂等去重键
trace_id: Mapped[str] = mapped_column(String(64), nullable=False)
# ===== 源平台(发起比价的那家)=====
source_platform_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
source_platform_name: Mapped[str | None] = mapped_column(String(32), nullable=True)
source_package: Mapped[str | None] = mapped_column(String(128), nullable=True)
source_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
# ===== 最优结果(全平台最便宜的一家,= comparison_results 里 rank=1)=====
best_platform_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
best_platform_name: Mapped[str | None] = mapped_column(String(32), nullable=True)
best_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 源价 - 最优价(可为 0 / 负:源平台本来就最便宜时没省到)
saved_amount_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 源平台就是最便宜的一家(= 这次没省到钱)
is_source_best: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
# ===== 订单概要 =====
store_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
total_dish_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
skipped_dish_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
# success(拿到有效对比)/ failed(出错或没采到目标价)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="success")
# done 帧 information 文案。成功:"在美团找到同店,到手价 ¥X…";
# 失败:具体原因(如"美团、京东外卖均未找到该商品")。前端在比价失败时当原因展示。
information: Mapped[str | None] = mapped_column(String(256), nullable=True)
# ===== 明细(JSON,越详细越好)=====
# 下单菜品 [{name, qty, specs?}]
items: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
# 逐平台对比 [{platform_id, platform_name, package, price, is_source, rank}](price 单位:元,原样存)
comparison_results: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
# 目标平台未找到、跳过的菜名
skipped_dish_names: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
# 客户端上报的原始 payload(calibration + done.params 全量),未来取数兜底
raw_payload: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return (
f"<ComparisonRecord id={self.id} user_id={self.user_id} "
f"trace_id={self.trace_id} status={self.status}>"
)
+36
View File
@@ -0,0 +1,36 @@
"""比价战绩里程碑领取记录表。
「记录比价战绩」每档(第 1~6 次)只能领一次,领取后写一行,(user_id, milestone)
唯一,防止重复领奖。解锁进度由 comparison_record 里 status='success' 的条数决定,
不存进度本身——只在这里记"哪几档已领"。仿 user_task 的一次性领取模型。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class ComparisonMilestoneClaim(Base):
__tablename__ = "comparison_milestone_claim"
__table_args__ = (
UniqueConstraint("user_id", "milestone", name="uq_compare_milestone_user"),
)
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
)
# 档位序号(1-based),见 app.core.rewards.RECORD_MILESTONES
milestone: Mapped[int] = mapped_column(Integer, nullable=False)
coin_awarded: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
claimed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<ComparisonMilestoneClaim user_id={self.user_id} m={self.milestone}>"
+147
View File
@@ -0,0 +1,147 @@
"""比价记录 CRUD:上报 upsert(按 user_id+trace_id 幂等) + 派生字段 + 明细分页。
派生逻辑:best_* / saved_amount_cents / is_source_best 全部从 comparison_results 算出
(协议保证已按 price 升序、rank=1 最便宜),客户端不用自己算、也不可信它算。
"""
from __future__ import annotations
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.comparison import ComparisonRecord
from app.schemas.compare_record import ComparisonRecordIn
def _yuan_to_cents(yuan: float | None) -> int | None:
"""元(float)→ 分(int)。None 透传。"""
if yuan is None:
return None
return round(yuan * 100)
def _derive(payload: ComparisonRecordIn) -> dict:
"""从上报 payload 派生结构化列(best/saved/is_source_best/status)。"""
results = payload.comparison_results
# 最优 = rank 最小的一条;协议已升序,但不信顺序,显式按 rank/price 兜底取最小价。
best = None
priced = [r for r in results if r.price is not None]
if priced:
best = min(
priced,
key=lambda r: (r.rank if r.rank is not None else 10**9, r.price),
)
source_price_cents = _yuan_to_cents(payload.source_price)
if source_price_cents is None:
# 源价没单独给,从 comparison_results 里的 is_source 行兜底
src_row = next((r for r in results if r.is_source and r.price is not None), None)
if src_row is not None:
source_price_cents = _yuan_to_cents(src_row.price)
best_price_cents = _yuan_to_cents(best.price) if best else None
saved_amount_cents = None
if source_price_cents is not None and best_price_cents is not None:
saved_amount_cents = source_price_cents - best_price_cents
is_source_best = best.is_source if best is not None else None
# status:客户端显式给了就用;否则有"非源且有价"的结果=success,否则 failed
status = payload.status
if status is None:
has_valid_target = any(
(not r.is_source) and r.price is not None for r in results
)
status = "success" if has_valid_target else "failed"
return {
"source_price_cents": source_price_cents,
"best_platform_id": best.platform_id if best else None,
"best_platform_name": best.platform_name if best else None,
"best_price_cents": best_price_cents,
"saved_amount_cents": saved_amount_cents,
"is_source_best": is_source_best,
"status": status,
}
def upsert_record(
db: Session, *, user_id: int, payload: ComparisonRecordIn
) -> ComparisonRecord:
"""按 (user_id, trace_id) 幂等写入:已存在则覆盖(更完整的重试上报胜出),否则新建。"""
derived = _derive(payload)
fields = dict(
device_id=payload.device_id,
business_type=payload.business_type,
store_name=payload.store_name,
source_platform_id=payload.source_platform_id,
source_platform_name=payload.source_platform_name,
source_package=payload.source_package,
information=payload.information,
total_dish_count=payload.total_dish_count,
skipped_dish_count=payload.skipped_dish_count,
items=[it.model_dump(exclude_none=True) for it in payload.items],
comparison_results=[r.model_dump() for r in payload.comparison_results],
skipped_dish_names=list(payload.skipped_dish_names),
raw_payload=payload.model_dump(),
**derived,
)
existing = db.execute(
select(ComparisonRecord).where(
ComparisonRecord.user_id == user_id,
ComparisonRecord.trace_id == payload.trace_id,
)
).scalar_one_or_none()
if existing is not None:
for k, v in fields.items():
setattr(existing, k, v)
db.commit()
db.refresh(existing)
return existing
rec = ComparisonRecord(user_id=user_id, trace_id=payload.trace_id, **fields)
db.add(rec)
db.commit()
db.refresh(rec)
return rec
def list_records(
db: Session,
user_id: int,
*,
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[ComparisonRecord], int | None]:
"""比价记录分页(按 id 倒序,游标式)。"""
stmt = select(ComparisonRecord).where(ComparisonRecord.user_id == user_id)
if cursor is not None:
stmt = stmt.where(ComparisonRecord.id < cursor)
stmt = stmt.order_by(ComparisonRecord.id.desc()).limit(limit)
items = list(db.execute(stmt).scalars().all())
next_cursor = items[-1].id if len(items) == limit else None
return items, next_cursor
def count_success(db: Session, user_id: int) -> int:
"""该用户成功比价(status='success')的条数。比价战绩里程碑的解锁进度源。"""
return db.execute(
select(func.count(ComparisonRecord.id)).where(
ComparisonRecord.user_id == user_id,
ComparisonRecord.status == "success",
)
).scalar_one()
def get_record(db: Session, user_id: int, record_id: int) -> ComparisonRecord | None:
"""取单条(限本人,避免越权读他人记录)。"""
return db.execute(
select(ComparisonRecord).where(
ComparisonRecord.id == record_id,
ComparisonRecord.user_id == user_id,
)
).scalar_one_or_none()
+116
View File
@@ -0,0 +1,116 @@
"""比价战绩里程碑 CRUD:进度查询 + 逐档领奖。
进度 = comparison_record 里 status='success' 的条数(crud_compare.count_success)。
第 N 档在"成功次数 >= N"时解锁;每档领一次,写 comparison_milestone_claim 去重 +
grant_coins,同一事务 commit(领奖记录与加金币原子化)。仿一次性任务 (task.py)。
"""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.rewards import (
RECORD_MILESTONE_COUNT,
RECORD_MILESTONES,
record_milestone_reward,
)
from app.models.comparison_milestone import ComparisonMilestoneClaim
from app.repositories import comparison as crud_compare
from app.repositories import wallet as crud_wallet
class UnknownMilestoneError(Exception):
"""档位序号越界(不在 1..RECORD_MILESTONE_COUNT)。"""
class MilestoneLockedError(Exception):
"""该档还没解锁(成功比价次数不够)。"""
class AlreadyClaimedError(Exception):
"""该档已经领过了。"""
@dataclass
class MilestoneState:
milestone: int # 1-based 档位序号
coin: int
state: str # claimed / active / locked
@dataclass
class MilestoneStatus:
success_count: int # 累计成功比价次数(解锁进度)
claimable_count: int # 当前可领(active)档数
milestones: list[MilestoneState]
def _claimed_set(db: Session, user_id: int) -> set[int]:
rows = db.execute(
select(ComparisonMilestoneClaim.milestone).where(
ComparisonMilestoneClaim.user_id == user_id
)
).scalars().all()
return set(rows)
def get_status(db: Session, user_id: int) -> MilestoneStatus:
"""各档领取状态:已领=claimed,已解锁未领=active(可领),未解锁=locked。"""
success_count = crud_compare.count_success(db, user_id)
claimed = _claimed_set(db, user_id)
milestones: list[MilestoneState] = []
claimable = 0
for m in range(1, RECORD_MILESTONE_COUNT + 1):
if m in claimed:
state = "claimed"
elif success_count >= m:
state = "active"
claimable += 1
else:
state = "locked"
milestones.append(
MilestoneState(milestone=m, coin=RECORD_MILESTONES[m - 1], state=state)
)
return MilestoneStatus(
success_count=success_count,
claimable_count=claimable,
milestones=milestones,
)
def claim(db: Session, user_id: int, milestone: int) -> tuple[int, int]:
"""领取第 milestone 档奖励。返回 (发放金币, 领奖后余额)。
越界抛 UnknownMilestoneError;未解锁抛 MilestoneLockedError;重复领抛 AlreadyClaimedError。
"""
if milestone < 1 or milestone > RECORD_MILESTONE_COUNT:
raise UnknownMilestoneError
existing = db.execute(
select(ComparisonMilestoneClaim).where(
ComparisonMilestoneClaim.user_id == user_id,
ComparisonMilestoneClaim.milestone == milestone,
)
).scalar_one_or_none()
if existing is not None:
raise AlreadyClaimedError
# 解锁校验:成功比价次数必须 >= 档位序号
if crud_compare.count_success(db, user_id) < milestone:
raise MilestoneLockedError
coin = record_milestone_reward(milestone)
db.add(
ComparisonMilestoneClaim(
user_id=user_id, milestone=milestone, coin_awarded=coin
)
)
acc, _ = crud_wallet.grant_coins(
db, user_id, coin, biz_type="compare_milestone", ref_id=str(milestone)
)
db.commit()
return coin, acc.coin_balance
+125
View File
@@ -0,0 +1,125 @@
"""比价记录上报 / 读取 schemas。
约定同 welfare:字段 snake_case、金额存整数(分)、时间 ISO 8601。
上报请求(ComparisonRecordIn)的字段刻意对齐 pricebot 协议
(docs/main/02_api_protocol.md 的 calibration + done.params.comparison_results),
让客户端把 Phase1 的 calibration 和 done 帧的 params 字段**零翻译**直接映射上来,
server 端负责拆成结构化列(best_*/saved/is_source_best 由 comparison_results 派生)。
"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
# ===== 上报请求 =====
class ComparisonItemIn(BaseModel):
"""下单菜品(来自 calibration.items)。"""
name: str
qty: int = 1
specs: list[str] | None = None
class ComparisonResultIn(BaseModel):
"""逐平台对比项(来自 done.params.comparison_results)。price 单位:元。"""
platform_id: str | None = None
platform_name: str | None = None
package: str | None = None
price: float | None = None
is_source: bool = False
rank: int | None = None
class ComparisonRecordIn(BaseModel):
# 幂等键:同一用户同一 trace_id 重复上报只保留一条(覆盖)
trace_id: str = Field(..., min_length=1, description="pricebot 侧 trace_id")
business_type: str = Field("food", description="food / ecom / coupon")
device_id: str | None = None
# 源平台(来自 calibration)
store_name: str | None = Field(None, description="店铺名(外卖)")
source_platform_id: str | None = None
source_platform_name: str | None = None
source_package: str | None = None
source_price: float | None = Field(None, description="源平台到手价(元)")
# 明细
items: list[ComparisonItemIn] = Field(default_factory=list)
comparison_results: list[ComparisonResultIn] = Field(default_factory=list)
skipped_dish_count: int | None = None
skipped_dish_names: list[str] = Field(default_factory=list)
total_dish_count: int | None = None
information: str | None = Field(None, description="done 帧文案,留存备查")
# 不传则服务端按 comparison_results 派生(有非源有效价=success,否则 failed)
status: str | None = Field(None, description="success / failed,可不传由服务端派生")
# ===== 读取出参 =====
class ComparisonRecordOut(BaseModel):
"""列表项:结构化概要字段(不含 raw_payload,减小列表 payload)。"""
model_config = ConfigDict(from_attributes=True)
id: int
business_type: str
trace_id: str
source_platform_id: str | None = None
source_platform_name: str | None = None
source_package: str | None = None
source_price_cents: int | None = None
best_platform_id: str | None = None
best_platform_name: str | None = None
best_price_cents: int | None = None
saved_amount_cents: int | None = None
is_source_best: bool | None = None
store_name: str | None = None
total_dish_count: int | None = None
skipped_dish_count: int | None = None
status: str
information: str | None = None
items: list = []
comparison_results: list = []
skipped_dish_names: list = []
created_at: datetime
class ComparisonRecordDetailOut(ComparisonRecordOut):
"""详情:在概要之上额外带 raw_payload 全量。"""
raw_payload: dict | None = None
class ComparisonRecordPage(BaseModel):
items: list[ComparisonRecordOut]
next_cursor: int | None = Field(None, description="下一页游标(末条 id);为空表示到底")
class ComparisonRecordCreatedOut(BaseModel):
id: int = Field(..., description="写入(或已存在)的记录 id")
# ===== 比价战绩里程碑(福利页「记录比价战绩」)=====
class MilestoneStateOut(BaseModel):
milestone: int = Field(..., description="档位序号 1-based(= 解锁所需的成功比价次数)")
coin: int = Field(..., description="该档奖励金币")
state: str = Field(..., description="claimed(已领) / active(可领) / locked(未解锁)")
class MilestoneStatusOut(BaseModel):
success_count: int = Field(..., description="累计成功比价次数(解锁进度)")
claimable_count: int = Field(..., description="当前可领(active)档数")
milestones: list[MilestoneStateOut]
class MilestoneClaimResultOut(BaseModel):
milestone: int = Field(..., description="本次领取的档位序号")
coin_awarded: int = Field(..., description="本次发放金币")
coin_balance: int = Field(..., description="领奖后金币余额")
+8 -1
View File
@@ -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-31
> 最后更新:2026-05-31+ 比价战绩里程碑 12d/12e
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
---
@@ -26,6 +26,13 @@
| **比价透传**(前缀 `/api/v1`,外卖 MVP;与 `coupon/step` 同为透传 pricebot-backend |||
| 12 | `POST /api/v1/intent/recognize` | 无 | [详情](./compare-intent-recognize.md) |
| 13 | `POST /api/v1/price/step` | 无 | [详情](./compare-price-step.md) |
| **比价记录**(前缀 `/api/v1/compare`;按用户落库,**鉴权**,区别于上面不鉴权的透传) |||
| 12a | `POST /api/v1/compare/record` | Bearer | [详情](./compare-record-report.md) |
| 12b | `GET /api/v1/compare/records` | Bearer | [详情](./compare-records.md) |
| 12c | `GET /api/v1/compare/records/{id}` | Bearer | [详情](./compare-record-detail.md) |
| **比价战绩里程碑**(前缀 `/api/v1/compare`;福利页「记录比价战绩」,按成功比价数解锁逐档发金币) |||
| 12d | `GET /api/v1/compare/milestones` | Bearer | [详情](./compare-milestones.md) |
| 12e | `POST /api/v1/compare/milestones/{milestone}/claim` | Bearer | [详情](./compare-milestone-claim.md) |
| **钱包 / 我的资产**(前缀 `/api/v1/wallet` |||
| 14 | `GET /api/v1/wallet/account` | Bearer | [详情](./wallet-account.md) |
| 15 | `GET /api/v1/wallet/coin-transactions` | Bearer | [详情](./wallet-coin-transactions.md) |
+33
View File
@@ -0,0 +1,33 @@
# POST /api/v1/compare/milestones/{milestone}/claim — 领取比价战绩里程碑奖励
> 所属:比价记录组(前缀 `/api/v1/compare` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
领取某一档(第 `milestone` 次)的金币奖励,真实入账金币钱包。领取记录写
`comparison_milestone_claim`((user_id, milestone) 唯一)+ `grant_coins` 同一事务原子提交,
**每档只能领一次**。进度口径见 [milestones 进度接口](./compare-milestones.md)。
## 入参
- 路径参数 `milestone`(int):档位序号,1..6(= `RECORD_MILESTONES` 长度)。
## 出参
响应 `200`
| 字段 | 类型 | 说明 |
|---|---|---|
| `milestone` | int | 本次领取的档位序号 |
| `coin_awarded` | int | 本次发放金币 |
| `coin_balance` | int | 领奖后金币余额 |
```json
{ "milestone": 1, "coin_awarded": 120, "coin_balance": 29087 }
```
## 错误
- `401` 未鉴权
- `404` 档位越界(`unknown milestone`,milestone < 1 或 > 档位总数)
- `409` 该档还没解锁(`milestone locked`,成功比价次数 < milestone)
- `409` 该档已领过(`milestone already claimed`)
## 说明
- 领取金币后,客户端应刷新钱包余额([`GET /api/v1/wallet/account`](./wallet-account.md))与本档状态。
- 幂等:重复领同一档返回 409 且**不会重复发金币**(唯一约束 + 领取前查重)。
+51
View File
@@ -0,0 +1,51 @@
# GET /api/v1/compare/milestones — 比价战绩里程碑进度
> 所属:比价记录组(前缀 `/api/v1/compare` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
福利页「记录比价战绩」的数据源。返回各档(第 1~6 次)解锁/领取状态。
**解锁进度** = 当前用户 `comparison_record``status='success'` 的条数(只算成功比价,失败的不计入)。第 N 档在「成功比价次数 ≥ N」时解锁;每档领一次(领取见 [claim 接口](./compare-milestone-claim.md))。
档位金额是**产品规则**,定义在后端 `app/core/rewards.py``RECORD_MILESTONES`(当前 `120/180/300/500/800/1200`),客户端**不要写死**,以本接口返回为准。
## 入参
无(用户身份取自 Bearer token)。
## 出参
响应 `200`
| 字段 | 类型 | 说明 |
|---|---|---|
| `success_count` | int | 累计成功比价次数(解锁进度) |
| `claimable_count` | int | 当前可领(state=active)的档数 |
| `milestones` | Milestone[] | 各档状态,按 milestone 升序 |
**Milestone**
| 字段 | 类型 | 说明 |
|---|---|---|
| `milestone` | int | 档位序号(1-based),= 解锁所需的成功比价次数 |
| `coin` | int | 该档奖励金币 |
| `state` | string | `claimed`(已领) / `active`(已解锁可领) / `locked`(未解锁) |
```json
{
"success_count": 2,
"claimable_count": 1,
"milestones": [
{"milestone": 1, "coin": 120, "state": "claimed"},
{"milestone": 2, "coin": 180, "state": "active"},
{"milestone": 3, "coin": 300, "state": "locked"},
{"milestone": 4, "coin": 500, "state": "locked"},
{"milestone": 5, "coin": 800, "state": "locked"},
{"milestone": 6, "coin": 1200, "state": "locked"}
]
}
```
## 错误
- `401` 未鉴权
## 说明
- 同时可有多档处于 `active`(如累计 3 次却一档没领,则前 3 档都可领),逐档调 claim。
- 进度只增不减:领取不消耗成功次数,只是把对应档从 active→claimed。
+25
View File
@@ -0,0 +1,25 @@
# GET /api/v1/compare/records/{record_id} — 比价记录详情
> 所属:比价记录组(前缀 `/api/v1/compare` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
单条比价记录详情,在列表项基础上额外带 `raw_payload`(客户端上报的原始全量),供未来 UI 展示任意细节。
## 入参(path
| 字段 | 类型 | 说明 |
|---|---|---|
| `record_id` | int | 记录 id |
## 出参
响应 `200`**ComparisonRecordDetailOut** = [ComparisonRecordOut](./compare-records.md#出参) 全部字段 + 下列:
| 字段 | 类型 | 说明 |
|---|---|---|
| `raw_payload` | object \| null | 客户端上报的原始 body 全量(calibration + done.params |
## 错误
- `401` 未鉴权
- `404` 记录不存在,或不属于当前用户(限本人,避免越权读他人记录)
## 说明
`404` 同时覆盖「id 不存在」和「id 属于他人」两种情况——不区分以免泄露他人记录是否存在。
+57
View File
@@ -0,0 +1,57 @@
# POST /api/v1/compare/record — 上报一次比价结果(幂等)
> 所属:比价记录组(前缀 `/api/v1/compare` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
比价 `done` 帧后,客户端用**带 JWT 的通道**上报一条比价结果,落 `comparison_record` 表,作为「我的比价记录」数据源 + 用户级行为画像。
> ⚠️ 与不鉴权的透传端点 [`/api/v1/price/step`](./compare-price-step.md) 不同:那是转发壳,本接口按用户维度落库,**必须鉴权**。
> 本轮只做 server 端;客户端在 done 帧后调本接口的改动另起一轮(见 [待办与技术债.md](../待办与技术债.md) P1)。
## 入参(JSON body
字段刻意对齐 pricebot 协议([02_api_protocol.md](../../../pricebot-backend/docs/main/02_api_protocol.md) 的 `calibration` + `done.params`),客户端把 Phase1 的 calibration 和 done 帧 params **零翻译**映射上来即可。
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `trace_id` | string | ✅ | — | pricebot 侧 trace_id。**幂等键**:同用户同 trace_id 重复上报覆盖、返回同一 id |
| `business_type` | string | ❌ | `food` | `food`(外卖,当前唯一接通) / `ecom`(电商) / `coupon`(领券) |
| `device_id` | string \| null | ❌ | null | 设备号 |
| `store_name` | string \| null | ❌ | null | 店铺名(外卖,来自 calibration.result |
| `source_platform_id` | string \| null | ❌ | null | 源平台代号,如 `taobao_flash` |
| `source_platform_name` | string \| null | ❌ | null | 源平台中文名 |
| `source_package` | string \| null | ❌ | null | 源平台 Android 包名 |
| `source_price` | float \| null | ❌ | null | 源平台到手价(**元** |
| `items` | Item[] | ❌ | [] | 下单菜品,`{name, qty, specs?}` |
| `comparison_results` | Result[] | ❌ | [] | 逐平台对比,见下表(price 单位**元**) |
| `skipped_dish_count` | int \| null | ❌ | null | 目标平台未找到、跳过的菜品数 |
| `skipped_dish_names` | string[] | ❌ | [] | 被跳过的菜名 |
| `total_dish_count` | int \| null | ❌ | null | 原始菜品总数 |
| `information` | string \| null | ❌ | null | done 帧文案(成功摘要 / 失败具体原因),持久化为列并在列表/详情返回,前端失败时当原因展示 |
| `status` | string \| null | ❌ | null | `success` / `failed`。**不传则服务端派生** |
**Item**`{ name: string, qty: int=1, specs: string[]\|null }`
**Resultcomparison_results 元素)**`{ platform_id, platform_name, package, price(元,float\|null), is_source(bool), rank(int\|null) }`
## 服务端派生(客户端不用算)
`comparison_results` 派生并落结构化列:
- `best_*`:取最便宜的一家(按 `rank` 升序、再按 `price`),写 `best_platform_id/name/best_price_cents`
- `source_price_cents`:优先用 `source_price`,缺失则取 `comparison_results``is_source=true` 行的价
- `saved_amount_cents` = `源价 最优价`(可为 0 / 负:源平台本来就最便宜)
- `is_source_best`:最便宜的一家是否为源平台(= 这次没省到)
- `status`(未显式传时):存在「非源且有价」的结果 → `success`,否则 → `failed`
- 金额一律 `round(元 × 100)` 存**分**
`comparison_results` / `items` / `skipped_dish_names` 原样存 JSON;完整上报 body 另存 `raw_payload`(详情接口可取回)。
## 出参
响应 `200``{ "id": int }`(写入或已存在记录的 id
## 错误
- `401` 未鉴权
- `422``trace_id` 或字段类型不符
## 说明
失败的比价(done 帧只有源/空 comparison_results)也会落一条 `status=failed`——「越详细越好」,是否在 UI 展示由前端过滤。
+47
View File
@@ -0,0 +1,47 @@
# GET /api/v1/compare/records — 比价记录列表(游标分页)
> 所属:比价记录组(前缀 `/api/v1/compare` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
「我的比价记录」列表页数据源。按 `id` 倒序(最新在前)。
## 入参(query
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `limit` | int | ❌ | 20 | 1100 |
| `cursor` | int | ❌ | null | 上一页末条 `id`,首页不传 |
## 出参
响应 `200``{ items: ComparisonRecordOut[], next_cursor: int|null }`(分页见 [索引#游标分页约定](./README.md#游标分页约定)
**ComparisonRecordOut**(列表项,不含 `raw_payload`,减小 payload
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 记录 id(也是游标) |
| `business_type` | string | `food` / `ecom` / `coupon` |
| `trace_id` | string | pricebot trace_id |
| `source_platform_id` | string \| null | 源平台代号 |
| `source_platform_name` | string \| null | 源平台中文名 |
| `source_package` | string \| null | 源平台包名 |
| `source_price_cents` | int \| null | 源平台到手价(分) |
| `best_platform_id` | string \| null | 最优平台代号 |
| `best_platform_name` | string \| null | 最优平台中文名 |
| `best_price_cents` | int \| null | 最优价(分) |
| `saved_amount_cents` | int \| null | 省下(分,可 0/负) |
| `is_source_best` | bool \| null | 源平台是否最便宜(= 没省到) |
| `store_name` | string \| null | 店铺名 |
| `total_dish_count` | int \| null | 菜品总数 |
| `skipped_dish_count` | int \| null | 跳过菜品数 |
| `status` | string | `success` / `failed` |
| `information` | string \| null | done 帧文案。成功:"在美团找到同店,到手价 ¥X…";失败:具体原因(如"美团、京东外卖均未找到该商品"),前端在 `status=failed` 时当原因展示 |
| `items` | object[] | 下单菜品 `{name, qty, specs?}` |
| `comparison_results` | object[] | 逐平台对比(price 单位元,已按 rank 升序) |
| `skipped_dish_names` | string[] | 被跳过的菜名 |
| `created_at` | datetime | 时间 |
## 错误
- `401` 未鉴权
## 说明
只返回当前登录用户自己的记录。需要单条全量(含 `raw_payload`)走 [详情接口](./compare-record-detail.md)。
+6 -1
View File
@@ -61,6 +61,7 @@ app/
│ ├── feedback.py # 帮助与反馈 1 端点(提交反馈含截图)
│ ├── coupon.py # 领券透传 /coupon/step(转发 pricebot,MVP 不鉴权)
│ ├── compare.py # 外卖比价透传 /intent/recognize + /price/step(转发 pricebot,MVP 不鉴权)
│ ├── compare_record.py# 比价记录 3 端点(上报 /compare/record + 列表 /compare/records + 详情;鉴权,区别于上面透传)
│ ├── meituan.py # 美团 3 端点 + feed 拼接(_interleave / _TOPIC_ROUNDS),未配 MT_CPS 凭证降级返空
│ ├── wallet.py # 钱包/提现 11 端点(余额/流水/兑换/绑微信/提现/查单)
│ ├── signin.py # 签到 2 端点(状态 / 执行签到)
@@ -73,6 +74,7 @@ app/
│ ├── feedback.py # 反馈出参(请求是 multipart,在 router 直接校验)
│ ├── meituan.py
│ ├── welfare.py # 钱包/签到/任务/省钱 收发模型
│ ├── compare_record.py # 比价记录上报/列表/详情 收发模型(字段对齐 pricebot calibration + done.params)
│ └── ad.py # 看广告发奖收发模型
├── integrations/ # 外部服务/SDK 客户端(重逻辑:签名/加解密/外部 HTTP)
│ ├── jiguang.py # 极光 REST 验 token + RSA 解密(多 padding 试错)
@@ -94,6 +96,7 @@ app/
│ ├── signin.py # 签到记录 / 连续天数 / 档位
│ ├── task.py # 一次性任务领取
│ ├── savings.py # 省钱汇总 / 战绩 / 明细
│ ├── comparison.py # 比价记录 upsert(user_id+trace_id 幂等)+ best/saved/status 派生 + 分页
│ └── ad_reward.py # 看广告发奖(按 trans_id 幂等 + 每日上限 + 本轮冷却派生)
├── models/ # ORM 表结构
│ ├── user.py # user(含微信 openid/nickname/avatar)
@@ -102,6 +105,7 @@ app/
│ ├── signin.py # 签到记录
│ ├── task.py # 任务领取记录
│ ├── savings.py # 省钱明细 / 店铺菜品 / dishes(PG 上 JSONB)
│ ├── comparison.py # 比价记录(完整明细;含 4 个 JSON(B) 列 + raw_payload;独立于 savings)
│ └── ad_reward.py # 看广告发奖记录
└── db/
├── base.py # DeclarativeBase
@@ -218,7 +222,7 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
## 7. 数据模型
10 张业务表 + `alembic_version` 框架表。生产 PG / 开发可回退 SQLite。
业务表(下表)+ `alembic_version` 框架表。生产 PG / 开发可回退 SQLite。
| 表 | models 文件 | 说明 |
|---|---|---|
@@ -231,6 +235,7 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
| `signin_record` | `models/signin.py` | 签到记录(每日一行,索引 `(user_id, signin_date)`) |
| `user_task` | `models/task.py` | 一次性任务领取记录 |
| `savings_record` | `models/savings.py` | 省钱明细(`dishes` 在 PG 上为 JSONB,SQLite 上是 TEXT) |
| `comparison_record` | `models/comparison.py` | 比价记录(每次比价完整明细;`(user_id, trace_id)` 唯一幂等;4 个 JSON(B) 列含 `raw_payload`;独立于 savings) |
| `ad_reward_record` | `models/ad_reward.py` | 看广告发奖(`trans_id` 唯一,幂等) |
**user 表字段速查**:
+6 -2
View File
@@ -11,8 +11,8 @@
- **现状**agent 系列接口 MVP 阶段**全部不鉴权**。领券 `coupon/step` **已落地不鉴权**(✅ 已去掉 `CurrentUser` 依赖);比价 4 个(`intent/recognize``price/step``ecom/intent/recognize``ecom/step`)端点尚未建(见 P2),建时同样先不加鉴权。
- **代价(为什么记这笔账)**:不验 JWT → 转发时 server 拿不到 `user_id` → agent 行为只能绑到 `device_id`(设备级),**采集不到"哪个用户领了/买了什么"的用户级画像**。而精准人群画像、私域分群运营是商业模式的核心资产,靠的就是这份用户级行为数据。`device_id` 仍照传(后端按设备串领券队列够用)。
- **待补**:① agent 接口加 JWT 鉴权;② 建立 `device_id ↔ user_id` 绑定(登录后上报一次即可);③ 领券/比价记录按 user 维度落库。
- **连带**:补鉴权后,客户端引擎 `ApiClient` 要接回 JWT(复用 app 现有 `AuthInterceptor` 思路:注入带鉴权头的 OkHttpClient)。
- **待补**:① agent 接口加 JWT 鉴权;② 建立 `device_id ↔ user_id` 绑定(登录后上报一次即可);③ ~~领券/比价记录按 user 维度落库~~**比价记录已落地**(见文末「已解决」):走**独立的鉴权端点** `POST /api/v1/compare/record`,客户端在 done 帧后用带 JWT 的通道上报、不依赖透传链路。**所以透传端点本身仍不鉴权、本条 P1 主诉求(给透传链路补 JWT + device↔user 绑定)未解**;领券记录尚未落库
- **连带**:补鉴权后,客户端引擎 `ApiClient` 要接回 JWT(复用 app 现有 `AuthInterceptor` 思路:注入带鉴权头的 OkHttpClient)。比价记录上报已**先行复用**这套(`authedClient``AuthInterceptor`+`RefreshAuthenticator`),可作透传链路补鉴权的参考样板。
---
@@ -87,6 +87,10 @@
## 已解决
-**比价记录落库(server + client,2026-05-31)**:每次比价 done 后客户端上报、按 user 落库,作「我的比价记录」数据源 + 用户级画像沉淀。
- **server**(本仓):新表 `comparison_record`(独立于 `savings_record`;结构化列 + `items`/`comparison_results`/`skipped_dish_names`/`raw_payload` 四个 JSON(B) 列)+ 3 个**鉴权**端点 `POST /api/v1/compare/record`(`(user_id,trace_id)` 幂等 upsert,best/saved/is_source_best/status 服务端从 comparison_results 派生)/ `GET /api/v1/compare/records`(游标分页)/ `GET /records/{id}`(含 raw_payload);`models/comparison.py` + `repositories/comparison.py` + `schemas/compare_record.py` + `api/v1/compare_record.py` + 迁移 `comparison_record_table`(head `b2c3d4e5f6a7`)+ `tests/test_compare_record.py`(8 例全过)+ `docs/api/compare-record-*.md`
- **client**(android 仓):`PriceBotService.runTask()` 比价 done 后(`lastDoneParams!=null`,成功/引擎失败都报)用独立 IO 协程尽力上报;`Protocol.CompareRecordRequest.fromComparison()` 从 calibration+done.params 零翻译组装;`ApiClient.reportCompareRecord()` 走新建的 `authedClient`(复用 `AuthInterceptor`+`RefreshAuthenticator`,自动 Bearer+401 刷新)。领券不报(非价格对比,本期范围只外卖)。
- **未做**:UI(「我的比价记录」页)等原型;真机端到端待验。
-**领券链路接通**(阶段 1):首页「去领取」→ `CouponPromptDialog` → 权限检查 → `startCouponClaim` → 循环 `/api/v1/coupon/step`;Running 悬浮窗换皮(ComposeView 承载 `CouponProgressPanel`/AgentFloat,`OverlayLifecycleOwner` 撑 Compose)。
-**coupon/step 去鉴权**:去掉 `CurrentUser`,MVP 不鉴权(device_id 透传)。
-**ApiClient 改址**:`BuildConfig.BASE_URL` + `/api/v1/` 前缀。
+136
View File
@@ -0,0 +1,136 @@
"""比价战绩里程碑测试:进度随成功比价数解锁 / 逐档领奖发金币 / 幂等 / 边界。
复用比价记录上报接口造成功记录(每条 trace 一次成功比价),再验里程碑。
"""
from __future__ import annotations
from app.core.rewards import RECORD_MILESTONES
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_success(client, token: str, trace_id: str) -> None:
"""上报一条成功比价(有非源有效价 → status 派生 success)。"""
payload = {
"trace_id": trace_id,
"business_type": "food",
"store_name": "测试店",
"source_platform_id": "taobao_flash",
"source_price": 30.0,
"comparison_results": [
{"platform_id": "meituan", "platform_name": "美团", "price": 25.0,
"is_source": False, "rank": 1},
{"platform_id": "taobao_flash", "platform_name": "淘宝闪购", "price": 30.0,
"is_source": True, "rank": 2},
],
}
r = client.post("/api/v1/compare/record", json=payload, headers=_auth(token))
assert r.status_code == 200, r.text
def _report_failed(client, token: str, trace_id: str) -> None:
"""上报一条失败比价(只有源 → status 派生 failed,不计入解锁)。"""
payload = {
"trace_id": trace_id,
"business_type": "food",
"comparison_results": [
{"platform_id": "taobao_flash", "platform_name": "淘宝闪购", "price": 30.0,
"is_source": True, "rank": 1},
],
}
r = client.post("/api/v1/compare/record", json=payload, headers=_auth(token))
assert r.status_code == 200, r.text
def test_status_empty(client) -> None:
"""没比价过:成功数 0,所有档 locked,无可领。"""
token = _login(client, "13800003001")
r = client.get("/api/v1/compare/milestones", headers=_auth(token))
assert r.status_code == 200, r.text
d = r.json()
assert d["success_count"] == 0
assert d["claimable_count"] == 0
assert len(d["milestones"]) == len(RECORD_MILESTONES)
assert all(m["state"] == "locked" for m in d["milestones"])
# 第 1 档金币对齐配置
assert d["milestones"][0]["coin"] == RECORD_MILESTONES[0]
assert d["milestones"][0]["milestone"] == 1
def test_unlock_progresses_with_success(client) -> None:
"""成功比价 2 次:前 2 档 active 可领,其余 locked。失败记录不计入。"""
token = _login(client, "13800003002")
_report_success(client, token, "s-1")
_report_success(client, token, "s-2")
_report_failed(client, token, "f-1") # 失败不计
d = client.get("/api/v1/compare/milestones", headers=_auth(token)).json()
assert d["success_count"] == 2
assert d["claimable_count"] == 2
states = [m["state"] for m in d["milestones"]]
assert states[0] == "active"
assert states[1] == "active"
assert states[2] == "locked"
def test_claim_grants_coin_and_becomes_claimed(client) -> None:
"""领第 1 档:发对应金币,进金币余额,该档变 claimed。"""
token = _login(client, "13800003003")
_report_success(client, token, "s-1")
bal_before = client.get("/api/v1/wallet/account", headers=_auth(token)).json()["coin_balance"]
r = client.post("/api/v1/compare/milestones/1/claim", headers=_auth(token))
assert r.status_code == 200, r.text
res = r.json()
assert res["milestone"] == 1
assert res["coin_awarded"] == RECORD_MILESTONES[0]
assert res["coin_balance"] == bal_before + RECORD_MILESTONES[0]
d = client.get("/api/v1/compare/milestones", headers=_auth(token)).json()
assert d["milestones"][0]["state"] == "claimed"
assert d["claimable_count"] == 0 # 第 1 档领掉了,成功数 1 没解锁第 2 档
def test_claim_idempotent_409(client) -> None:
"""同档重复领 → 409,且不重复发金币。"""
token = _login(client, "13800003004")
_report_success(client, token, "s-1")
assert client.post("/api/v1/compare/milestones/1/claim", headers=_auth(token)).status_code == 200
bal = client.get("/api/v1/wallet/account", headers=_auth(token)).json()["coin_balance"]
r = client.post("/api/v1/compare/milestones/1/claim", headers=_auth(token))
assert r.status_code == 409
bal2 = client.get("/api/v1/wallet/account", headers=_auth(token)).json()["coin_balance"]
assert bal2 == bal # 没重复发
def test_claim_locked_409(client) -> None:
"""领还没解锁的档(成功数不够)→ 409 locked。"""
token = _login(client, "13800003005")
_report_success(client, token, "s-1") # 只解锁第 1 档
r = client.post("/api/v1/compare/milestones/2/claim", headers=_auth(token))
assert r.status_code == 409
def test_claim_unknown_milestone_404(client) -> None:
"""档位越界 → 404。"""
token = _login(client, "13800003006")
assert client.post("/api/v1/compare/milestones/0/claim", headers=_auth(token)).status_code == 404
big = len(RECORD_MILESTONES) + 1
assert client.post(f"/api/v1/compare/milestones/{big}/claim", headers=_auth(token)).status_code == 404
def test_requires_auth(client) -> None:
"""不带 token → 401。"""
assert client.get("/api/v1/compare/milestones").status_code == 401
assert client.post("/api/v1/compare/milestones/1/claim").status_code == 401
+192
View File
@@ -0,0 +1,192 @@
"""比价记录测试:上报派生 / 幂等 / 分页 / 详情 / 鉴权 / 失败帧。
sms mock 登录拿 token,再跑各接口闭环
"""
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 _food_payload(trace_id: str) -> dict:
"""一次淘宝闪购→美团/京东 的成功比价(美团最便宜)。"""
return {
"trace_id": trace_id,
"business_type": "food",
"device_id": "dev-abc",
"store_name": "海底捞(朝阳店)",
"source_platform_id": "taobao_flash",
"source_platform_name": "淘宝闪购",
"source_package": "com.taobao.taobao",
"source_price": 128.50,
"items": [
{"name": "麻辣午餐肉", "qty": 2},
{"name": "黑牛肉卷", "qty": 1, "specs": ["大份"]},
],
"comparison_results": [
{"platform_id": "meituan", "platform_name": "美团", "package": "com.sankuai.meituan",
"price": 123.50, "is_source": False, "rank": 1},
{"platform_id": "taobao_flash", "platform_name": "淘宝闪购", "package": "com.taobao.taobao",
"price": 128.50, "is_source": True, "rank": 2},
{"platform_id": "jd_waimai", "platform_name": "京东外卖", "package": "com.jingdong.app.mall",
"price": 130.00, "is_source": False, "rank": 3},
],
"skipped_dish_count": 1,
"skipped_dish_names": ["黑牛肉卷"],
"total_dish_count": 3,
"information": "在美团找到同店,到手价 ¥123.50",
}
def test_report_and_derive(client) -> None:
"""上报成功比价:服务端派生 best/saved/is_source_best/分,详情可读回。"""
token = _login(client, "13800002001")
r = client.post("/api/v1/compare/record", json=_food_payload("trace-1"), headers=_auth(token))
assert r.status_code == 200, r.text
rec_id = r.json()["id"]
assert isinstance(rec_id, int)
r = client.get(f"/api/v1/compare/records/{rec_id}", headers=_auth(token))
assert r.status_code == 200, r.text
d = r.json()
# 金额转分
assert d["source_price_cents"] == 12850
assert d["best_price_cents"] == 12350
# 省 = 源价 - 最优价 = 5.00 元
assert d["saved_amount_cents"] == 500
# 最优是美团(rank=1),非源平台 → 没"源平台最便宜"
assert d["best_platform_id"] == "meituan"
assert d["is_source_best"] is False
assert d["status"] == "success"
assert d["information"] == "在美团找到同店,到手价 ¥123.50"
assert d["store_name"] == "海底捞(朝阳店)"
assert d["total_dish_count"] == 3
assert d["skipped_dish_count"] == 1
assert d["skipped_dish_names"] == ["黑牛肉卷"]
assert len(d["comparison_results"]) == 3
assert len(d["items"]) == 2
# 详情带 raw_payload 全量
assert d["raw_payload"]["trace_id"] == "trace-1"
def test_source_is_cheapest_no_saving(client) -> None:
"""源平台本来就最便宜:saved<=0,is_source_best=True,仍记一条 success。"""
token = _login(client, "13800002002")
payload = _food_payload("trace-2")
# 把源平台价改到最低 + rank=1
payload["source_price"] = 100.00
payload["comparison_results"] = [
{"platform_id": "taobao_flash", "platform_name": "淘宝闪购", "package": "com.taobao.taobao",
"price": 100.00, "is_source": True, "rank": 1},
{"platform_id": "meituan", "platform_name": "美团", "package": "com.sankuai.meituan",
"price": 123.50, "is_source": False, "rank": 2},
]
r = client.post("/api/v1/compare/record", json=payload, headers=_auth(token))
rec_id = r.json()["id"]
d = client.get(f"/api/v1/compare/records/{rec_id}", headers=_auth(token)).json()
assert d["best_platform_id"] == "taobao_flash"
assert d["is_source_best"] is True
assert d["saved_amount_cents"] == 0 # 100 - 100
assert d["status"] == "success" # 有非源有效价(美团)
def test_failed_comparison_recorded(client) -> None:
"""比价失败(只有源、无目标有效价):status=failed 也落一条。"""
token = _login(client, "13800002003")
payload = _food_payload("trace-3")
payload["comparison_results"] = [
{"platform_id": "taobao_flash", "platform_name": "淘宝闪购", "package": "com.taobao.taobao",
"price": 128.50, "is_source": True, "rank": 1},
]
r = client.post("/api/v1/compare/record", json=payload, headers=_auth(token))
rec_id = r.json()["id"]
d = client.get(f"/api/v1/compare/records/{rec_id}", headers=_auth(token)).json()
assert d["status"] == "failed"
assert d["best_platform_id"] == "taobao_flash" # 唯一一条
# 源价有、最优价=源价 → saved=0(没采到更便宜目标)
assert d["saved_amount_cents"] == 0
def test_idempotent_same_trace(client) -> None:
"""同 trace_id 重复上报:返回同一 id,只一条记录,后报覆盖。"""
token = _login(client, "13800002004")
p = _food_payload("trace-dup")
r1 = client.post("/api/v1/compare/record", json=p, headers=_auth(token))
id1 = r1.json()["id"]
# 改个值再报一次(同 trace)
p["store_name"] = "改名后的店"
r2 = client.post("/api/v1/compare/record", json=p, headers=_auth(token))
id2 = r2.json()["id"]
assert id1 == id2 # 同一条
# 列表里只有一条
page = client.get("/api/v1/compare/records", headers=_auth(token)).json()
assert len(page["items"]) == 1
# 覆盖生效
assert page["items"][0]["store_name"] == "改名后的店"
def test_records_pagination_and_isolation(client) -> None:
"""分页 id 倒序 + 游标;只看得到自己的记录。"""
token_a = _login(client, "13800002005")
for i in range(5):
client.post("/api/v1/compare/record", json=_food_payload(f"a-{i}"), headers=_auth(token_a))
# 另一个用户的记录不应混入
token_b = _login(client, "13800002006")
client.post("/api/v1/compare/record", json=_food_payload("b-0"), headers=_auth(token_b))
r = client.get("/api/v1/compare/records?limit=3", headers=_auth(token_a))
page1 = r.json()
assert len(page1["items"]) == 3
assert page1["next_cursor"] is not None
ids = [it["id"] for it in page1["items"]]
assert ids == sorted(ids, reverse=True)
r = client.get(
f"/api/v1/compare/records?limit=3&cursor={page1['next_cursor']}",
headers=_auth(token_a),
)
page2 = r.json()
assert len(page2["items"]) == 2 # A 共 5 条
assert max(it["id"] for it in page2["items"]) < min(ids)
# B 只看到自己那一条
pb = client.get("/api/v1/compare/records", headers=_auth(token_b)).json()
assert len(pb["items"]) == 1
def test_detail_cross_user_404(client) -> None:
"""读他人记录详情 → 404(限本人)。"""
token_a = _login(client, "13800002007")
rid = client.post(
"/api/v1/compare/record", json=_food_payload("x-0"), headers=_auth(token_a)
).json()["id"]
token_b = _login(client, "13800002008")
r = client.get(f"/api/v1/compare/records/{rid}", headers=_auth(token_b))
assert r.status_code == 404
def test_requires_auth(client) -> None:
"""不带 token 统一 401。"""
assert client.post("/api/v1/compare/record", json={"trace_id": "t"}).status_code == 401
assert client.get("/api/v1/compare/records").status_code == 401
assert client.get("/api/v1/compare/records/1").status_code == 401
def test_trace_id_required(client) -> None:
"""缺 trace_id → 422(pydantic 校验)。"""
token = _login(client, "13800002009")
r = client.post("/api/v1/compare/record", json={"business_type": "food"}, headers=_auth(token))
assert r.status_code == 422