Compare commits

...

5 Commits

Author SHA1 Message Date
OuYingJun1024 9cfe5fc6e6 Merge branch 'main' of https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server into feat/ad-ecpm-and-cooldown 2026-06-01 21:42:47 +08:00
OuYingJun1024 8106899bfa chore(ad): 激励视频单次发奖金币 100→666 + 同步测试期望值 2026-06-01 21:11:31 +08:00
OuYingJun1024 bb58fb2a29 docs(database): 新建 docs/database 表文档 — 13 张表全量 + 索引
按"一表一文件"建库表文档(协作规范第 11 条):字段(类型/约束/默认)+
索引与约束 + 关系 + 说明,从 model 的 Mapped 定义扒准。

- README 索引:13 张表总览 + 通用约定(主键/外键/金额分/北京时间日期串/JSONB variant)
- 覆盖 user / coin_account / coin_transaction / cash_transaction / withdraw_order /
  signin_record / user_task / savings_record / ad_reward_record / ad_ecpm_record /
  feedback / comparison_record / comparison_milestone_claim
- savings_record 文档强调"成交省钱"≠"比价行为",与 comparison_record 互不喂数据

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:44:54 +08:00
OuYingJun1024 15dcfa604b 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>
2026-05-31 19:56:47 +08:00
OuYingJun1024 eb5f0dc460 feat(ad): eCPM 上报接口 + 冷却策略解耦纯函数 + savings JSONB 跨库修复
- 新增 POST /api/v1/ad/ecpm-report:激励视频展示后客户端上报本次 eCPM,落 ad_ecpm_record 做内部收益统计(model/repository/schema/alembic 迁移 + 接口文档)
- 看广告冷却策略抽到 app/core/ad_cooldown.py 纯函数;ad_reward.today_status 只取数据,换策略只改这一处
- savings.dishes 改 JSON().with_variant(JSONB,"postgresql"):SQLite 无 visit_JSONB 会让 create_all 编译崩,修复后测试套件恢复

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 14:52:01 +08:00
46 changed files with 2128 additions and 48 deletions
+47
View File
@@ -0,0 +1,47 @@
"""ad_ecpm_record table (广告展示 eCPM 上报记录,内部收益统计/对账)
Revision ID: a1b2c3d4e5f6
Revises: f01db5d77dac
Create Date: 2026-05-31 11:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a1b2c3d4e5f6'
down_revision: Union[str, Sequence[str], None] = 'f01db5d77dac'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'ad_ecpm_record',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('ad_type', sa.String(length=32), nullable=False),
sa.Column('adn', sa.String(length=32), nullable=True),
sa.Column('slot_id', sa.String(length=64), nullable=True),
sa.Column('ecpm_raw', sa.String(length=32), nullable=False),
sa.Column('report_date', sa.String(length=10), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
)
with op.batch_alter_table('ad_ecpm_record', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_ad_ecpm_record_user_id'), ['user_id'], unique=False)
batch_op.create_index(batch_op.f('ix_ad_ecpm_record_report_date'), ['report_date'], unique=False)
batch_op.create_index(batch_op.f('ix_ad_ecpm_record_created_at'), ['created_at'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('ad_ecpm_record', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_ad_ecpm_record_created_at'))
batch_op.drop_index(batch_op.f('ix_ad_ecpm_record_report_date'))
batch_op.drop_index(batch_op.f('ix_ad_ecpm_record_user_id'))
op.drop_table('ad_ecpm_record')
@@ -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')
+32 -1
View File
@@ -19,8 +19,15 @@ from app.core import rewards
from app.core.config import settings from app.core.config import settings
from app.integrations import pangle from app.integrations import pangle
from app.core.ratelimit import rate_limit from app.core.ratelimit import rate_limit
from app.repositories import ad_ecpm as crud_ecpm
from app.repositories import ad_reward as crud_ad from app.repositories import ad_reward as crud_ad
from app.schemas.ad import AdRewardStatusOut, PangleCallbackOut, TestGrantOut from app.schemas.ad import (
AdRewardStatusOut,
EcpmReportIn,
EcpmReportOut,
PangleCallbackOut,
TestGrantOut,
)
logger = logging.getLogger("shagua.ad") logger = logging.getLogger("shagua.ad")
@@ -95,6 +102,30 @@ def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut:
) )
@router.post(
"/ecpm-report",
response_model=EcpmReportOut,
summary="上报本次广告展示的 eCPM(内部收益统计)",
dependencies=[Depends(rate_limit(120, 60, "ad-ecpm-report"))],
)
def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> EcpmReportOut:
"""客户端在广告展示后(onAdShow 读 getShowEcpm)上报 eCPM,落库做内部收益统计/对账。
Bearer 鉴权,user_id 取自 JWT(不信 body)。best-effort:落库即 ok,客户端 fire-and-forget,
丢一两条不影响业务(穿山甲后台报表是结算权威)。eCPM 与发奖(S2S)是两条独立流,不逐条关联。
"""
crud_ecpm.create_ecpm_record(
db, user.id,
ad_type=payload.ad_type, ecpm_raw=payload.ecpm,
adn=payload.adn, slot_id=payload.slot_id,
)
logger.info(
"ad ecpm report user_id=%d type=%s ecpm=%s adn=%s slot=%s",
user.id, payload.ad_type, payload.ecpm, payload.adn, payload.slot_id,
)
return EcpmReportOut(ok=True)
@router.post( @router.post(
"/test-grant", "/test-grant",
response_model=TestGrantOut, response_model=TestGrantOut,
+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)
+54
View File
@@ -0,0 +1,54 @@
"""看激励视频冷却策略 —— 与发奖记录查询解耦的纯计算。
当前策略:**每 N 次一轮,看满一轮后强制冷却若干秒**(N / 秒数 取自 [rewards] 常量)。
[repositories.ad_reward.today_status] 只负责取数据(今日 granted 的 created_at 列表),
"本轮已看几次 + 冷却到几点"的策略判断委托到这里。
⚠️ 这是临时策略,后续要调。换策略(间隔式 / 每日配额式 / 指数退避 …)**只改本文件**,
repository 不碰——这就是把它独立出来的目的。保持 [compute_cooldown] 签名稳定即可。
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from app.core.rewards import VIDEO_ROUND_COOLDOWN_SECONDS, VIDEO_ROUND_REQUIRED_COUNT
@dataclass(frozen=True)
class CooldownState:
"""冷却策略的输出。"""
round_count: int # 本轮已看次数 0..N-1(展示用)
cooldown_until: datetime | None # 本轮冷却结束时间(UTC);None = 不在冷却
def compute_cooldown(
granted_times_desc: list[datetime],
now: datetime,
*,
round_size: int = VIDEO_ROUND_REQUIRED_COUNT,
cooldown_seconds: int = VIDEO_ROUND_COOLDOWN_SECONDS,
) -> CooldownState:
""""每 round_size 次一轮、看满一轮后冷却 cooldown_seconds 秒"算本轮进度 + 冷却结束时间。
:param granted_times_desc: 今日 status=granted 记录的 created_at,**按时间倒序**(最新在前)。
:param now: 当前时间(UTC,带 tzinfo),用于判断冷却是否已过。
:param round_size / cooldown_seconds: 策略参数,默认取 rewards 常量,可注入便于测试/调参。
纯函数,不碰 DB。冷却派生算法:把今日 granted 倒序,跳过当前未完成轮的 round_count 条,
下一条即"上一轮最后一次"的时间,+ cooldown_seconds 仍 > now 则在冷却中。
SQLite 上 created_at 可能是 naive,按 UTC 解读再比较。
"""
used = len(granted_times_desc)
round_count = used % round_size
cooldown_until: datetime | None = None
if used >= round_size:
# round_count 必 < round_size <= used,索引合法
last_round_end = granted_times_desc[round_count]
if last_round_end.tzinfo is None:
last_round_end = last_round_end.replace(tzinfo=timezone.utc)
cd_end = last_round_end + timedelta(seconds=cooldown_seconds)
if cd_end > now:
cooldown_until = cd_end
return CooldownState(round_count=round_count, cooldown_until=cooldown_until)
+16 -3
View File
@@ -58,13 +58,26 @@ 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 服务端回调发奖)===== # ===== 看激励视频发金币(穿山甲 S2S 服务端回调发奖)=====
# 看完一个激励视频发的金币(100 金币 ≈¥0.01,汇率 10000 金币=1 元)。 # 看完一个激励视频发的金币(666 金币 ≈¥0.0666,汇率 10000 金币=1 元)。
# 作用:① 回调缺/坏 reward_amount 时的回退值;② 客户端进度接口展示的"单次预告金币"; # 作用:① 回调缺/坏 reward_amount 时的回退值;② 客户端进度接口展示的"单次预告金币";
# ③ test-grant 本地联调的发奖额。 # ③ test-grant 本地联调的发奖额。
# 真实发放以穿山甲回调带回的 reward_amount 为准(见 resolve_ad_reward_coin),后台应把 # 真实发放以穿山甲回调带回的 reward_amount 为准(见 resolve_ad_reward_coin),后台应把
# 代码位"奖励数量"配成与本值一致(=100),保证"广告内展示 / 进度预告 / 实际到账"三者一致。 # 代码位"奖励数量"配成与本值一致(=666),保证"广告内展示 / 进度预告 / 实际到账"三者一致。
AD_REWARD_COIN: int = 100 AD_REWARD_COIN: int = 666
# 单次发奖金币上限:夹紧穿山甲回调里异常的 reward_amount(如后台多打一个 0),防刷爆余额。 # 单次发奖金币上限:夹紧穿山甲回调里异常的 reward_amount(如后台多打一个 0),防刷爆余额。
MAX_AD_REWARD_COIN: int = 1000 MAX_AD_REWARD_COIN: int = 1000
# 每用户每日发奖次数上限,防刷 + 控成本。 # 每用户每日发奖次数上限,防刷 + 控成本。
+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.ad import router as ad_router
from app.api.v1.auth import router as auth_router from app.api.v1.auth import router as auth_router
from app.api.v1.compare import router as compare_router from app.api.v1.compare import router as compare_router
from app.api.v1.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.coupon import router as coupon_router
from app.api.v1.feedback import router as feedback_router from app.api.v1.feedback import router as feedback_router
from app.api.v1.meituan import router as meituan_router from app.api.v1.meituan import router as meituan_router
@@ -75,6 +77,8 @@ app.include_router(user_router)
app.include_router(feedback_router) app.include_router(feedback_router)
app.include_router(coupon_router) app.include_router(coupon_router)
app.include_router(compare_router) app.include_router(compare_router)
app.include_router(compare_record_router)
app.include_router(compare_milestone_router)
app.include_router(meituan_router) app.include_router(meituan_router)
app.include_router(wallet_router) app.include_router(wallet_router)
app.include_router(signin_router) app.include_router(signin_router)
+3
View File
@@ -1,5 +1,8 @@
"""所有 ORM model 必须在这里 import 一次,Alembic / metadata 才能扫到。""" """所有 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.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.feedback import Feedback # noqa: F401
from app.models.savings import SavingsRecord # noqa: F401 from app.models.savings import SavingsRecord # noqa: F401
from app.models.signin import SigninRecord # noqa: F401 from app.models.signin import SigninRecord # noqa: F401
+49
View File
@@ -0,0 +1,49 @@
"""广告展示 eCPM 上报记录(内部收益统计/对账)。
每条 = 客户端一次广告展示(`onAdShow`)后读到的 eCPM 信息。和发奖记录
[ad_reward.AdRewardRecord] 是**两条独立的数据流**:
- 发奖走穿山甲 S2S 回调(后端 → 有 trans_id、无 ecpm);
- eCPM 走客户端上报(客户端 → 有 ecpm、无 trans_id)。
两者没有公共键,无法逐条一一对应,所以本表用于**按用户/按天聚合收益**口径的对账,
不做"这条发奖 = 这条 ecpm"的精确关联。穿山甲后台报表才是结算权威,本表是细粒度补充。
⚠️ `ecpm_raw` 原样存客户端上报的字符串——eCPM 单位(分 / 元)截至 2026-05-31 尚未最终确认,
确认后再加一列解析好的数值;在此之前对账按"待定单位"处理。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class AdEcpmRecord(Base):
__tablename__ = "ad_ecpm_record"
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
)
# 广告类型:reward_video(激励视频) / draw(Draw 信息流) 等;不强行统一代码位,各类型各自上报
ad_type: Mapped[str] = mapped_column(String(32), nullable=False)
# 实际投放的 ADN(穿山甲 getShowEcpm().getSdkName(),如 pangle / gdt)
adn: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 实际展示用的代码位(底层 mediation rit,非客户端配置位)
slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 客户端上报的 eCPM 原始字符串(单位待确认,原样存)
ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False)
# 北京时间日期串 'YYYY-MM-DD',按它等值做"按天聚合"(不在 SQL 里做跨时区 date 比较)
report_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False)
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"<AdEcpmRecord user_id={self.user_id} {self.ad_type} "
f"ecpm={self.ecpm_raw} adn={self.adn}>"
)
+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}>"
+49
View File
@@ -0,0 +1,49 @@
"""广告 eCPM 上报 CRUD(内部收益统计/对账)。
客户端在广告展示后(onAdShow)读到 eCPM,经鉴权接口上报,这里落库。鉴权接口已确保
user 存在(JWT),故不做 UnknownUser 校验。best-effort 上报:丢一两条不影响业务,
穿山甲后台报表是结算权威兜底。
"""
from __future__ import annotations
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.rewards import cn_today
from app.models.ad_ecpm import AdEcpmRecord
def create_ecpm_record(
db: Session,
user_id: int,
*,
ad_type: str,
ecpm_raw: str,
adn: str | None = None,
slot_id: str | None = None,
) -> AdEcpmRecord:
"""落一条 eCPM 上报记录。report_date 用北京时间当天,供按天聚合。"""
rec = AdEcpmRecord(
user_id=user_id,
ad_type=ad_type,
adn=adn,
slot_id=slot_id,
ecpm_raw=ecpm_raw,
report_date=cn_today().isoformat(),
)
db.add(rec)
db.commit()
db.refresh(rec)
return rec
def count_today(db: Session, user_id: int) -> int:
"""该用户今日(北京时间)上报的 eCPM 条数,排查/对账辅助用。"""
return db.execute(
select(func.count())
.select_from(AdEcpmRecord)
.where(
AdEcpmRecord.user_id == user_id,
AdEcpmRecord.report_date == cn_today().isoformat(),
)
).scalar_one()
+26 -39
View File
@@ -10,17 +10,16 @@
""" """
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import datetime, timezone
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.ad_cooldown import compute_cooldown
from app.core.rewards import ( from app.core.rewards import (
AD_REWARD_COIN, AD_REWARD_COIN,
DAILY_AD_REWARD_LIMIT, DAILY_AD_REWARD_LIMIT,
VIDEO_ROUND_COOLDOWN_SECONDS,
VIDEO_ROUND_REQUIRED_COUNT,
cn_today, cn_today,
) )
from app.repositories import wallet as crud_wallet from app.repositories import wallet as crud_wallet
@@ -110,26 +109,19 @@ def _commit_record(db: Session, rec: AdRewardRecord, trans_id: str) -> AdRewardR
return rec return rec
def _last_completed_round_end_at( def _granted_times_today_desc(db: Session, user_id: int, reward_date: str) -> list[datetime]:
db: Session, user_id: int, reward_date: str, round_count: int """当日 status=granted 记录的 created_at,按时间倒序(最新在前)——冷却策略的输入数据。"""
) -> datetime | None: return list(
"""当日 granted 记录中**最近一个已完成轮**末尾那次的 created_at。 db.execute(
select(AdRewardRecord.created_at)
思路:把当日 granted 按时间倒序排,跳过当前未完成轮的 round_count 条,下一条 .where(
"上一轮最后一次"。round_count==0 且 used>=N 时跳 0 条直接取最近一条。 AdRewardRecord.user_id == user_id,
used<N(还没完成第一轮)调用方应直接判 None,不进这里。 AdRewardRecord.reward_date == reward_date,
""" AdRewardRecord.status == "granted",
return db.execute( )
select(AdRewardRecord.created_at) .order_by(AdRewardRecord.created_at.desc())
.where( ).scalars()
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( def today_status(
@@ -138,21 +130,16 @@ def today_status(
"""客户端查"今日看广告发奖"进度。 """客户端查"今日看广告发奖"进度。
返回 (今日已发次数, 每日上限, 单次金币, 本轮已看次数, 本轮冷却结束时间(UTC))。 返回 (今日已发次数, 每日上限, 单次金币, 本轮已看次数, 本轮冷却结束时间(UTC))。
- round_count = used % VIDEO_ROUND_REQUIRED_COUNT,展示用(0..N-1) 本函数只**取数据**(今日 granted 的 created_at 倒序),把"本轮已看几次 + 冷却到几点"
- cooldown_until 计算:取最近一个已完成轮末尾的 created_at + 10 min;若仍 > now 则返回, **策略判断**委托给 [app.core.ad_cooldown.compute_cooldown](纯函数)——换冷却策略只动那个模块。
否则返回 None。冷却 = UX 约束(客户端 CTA 倒计时不可点),后端发奖逻辑不受影响。
""" """
today = cn_today().isoformat() today = cn_today().isoformat()
used = _granted_today(db, user_id, today) granted_desc = _granted_times_today_desc(db, user_id, today)
round_count = used % VIDEO_ROUND_REQUIRED_COUNT state = compute_cooldown(granted_desc, datetime.now(timezone.utc))
cooldown_until: datetime | None = None return (
if used >= VIDEO_ROUND_REQUIRED_COUNT: len(granted_desc),
last_end = _last_completed_round_end_at(db, user_id, today, round_count) DAILY_AD_REWARD_LIMIT,
if last_end is not None: AD_REWARD_COIN,
# SQLAlchemy 在 SQLite 上拿到的 created_at 可能是 naive,统一按 UTC 解读再比较 state.round_count,
if last_end.tzinfo is None: state.cooldown_until,
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
+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
+18
View File
@@ -39,6 +39,24 @@ class AdRewardStatusOut(BaseModel):
) )
class EcpmReportIn(BaseModel):
"""客户端上报一次广告展示的 eCPM(内部收益统计/对账)。
user_id 不在 body 里——由 JWT 取(Bearer),防伪造。ecpm 原样上报字符串(单位待确认)。
"""
ad_type: str = Field(..., description="广告类型:reward_video(激励视频) / draw(Draw 信息流) 等")
ecpm: str = Field(..., description="穿山甲 getShowEcpm().getEcpm() 原始字符串,单位待确认,原样上报")
adn: str | None = Field(None, description="实际投放 ADN(getSdkName),如 pangle")
slot_id: str | None = Field(None, description="实际展示代码位(底层 mediation rit)")
class EcpmReportOut(BaseModel):
"""eCPM 上报结果。best-effort,落库即 ok。"""
ok: bool = True
class TestGrantOut(BaseModel): class TestGrantOut(BaseModel):
"""[仅本地联调]模拟发奖结果。带上今日进度,客户端可直接据此刷新展示。""" """[仅本地联调]模拟发奖结果。带上今日进度,客户端可直接据此刷新展示。"""
+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="领奖后金币余额")
+9 -1
View File
@@ -3,7 +3,7 @@
> Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770` > Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770`
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case** > 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**
> 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer <access_token>` > 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer <access_token>`
> 最后更新:2026-05-31 > 最后更新:2026-05-31+ 比价战绩里程碑 12d/12e
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。 > 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
--- ---
@@ -26,6 +26,13 @@
| **比价透传**(前缀 `/api/v1`,外卖 MVP;与 `coupon/step` 同为透传 pricebot-backend ||| | **比价透传**(前缀 `/api/v1`,外卖 MVP;与 `coupon/step` 同为透传 pricebot-backend |||
| 12 | `POST /api/v1/intent/recognize` | 无 | [详情](./compare-intent-recognize.md) | | 12 | `POST /api/v1/intent/recognize` | 无 | [详情](./compare-intent-recognize.md) |
| 13 | `POST /api/v1/price/step` | 无 | [详情](./compare-price-step.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` ||| | **钱包 / 我的资产**(前缀 `/api/v1/wallet` |||
| 14 | `GET /api/v1/wallet/account` | Bearer | [详情](./wallet-account.md) | | 14 | `GET /api/v1/wallet/account` | Bearer | [详情](./wallet-account.md) |
| 15 | `GET /api/v1/wallet/coin-transactions` | Bearer | [详情](./wallet-coin-transactions.md) | | 15 | `GET /api/v1/wallet/coin-transactions` | Bearer | [详情](./wallet-coin-transactions.md) |
@@ -52,6 +59,7 @@
| 32 | `GET /api/v1/ad/pangle-callback` | 验签 | [详情](./ad-pangle-callback.md) | | 32 | `GET /api/v1/ad/pangle-callback` | 验签 | [详情](./ad-pangle-callback.md) |
| 33 | `GET /api/v1/ad/reward-status` | Bearer | [详情](./ad-reward-status.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) | | 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad-test-grant.md) |
| 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad-ecpm-report.md) |
| **用户资料**(前缀 `/api/v1/user` ||| | **用户资料**(前缀 `/api/v1/user` |||
| 35 | `PATCH /api/v1/user/profile` | Bearer | [详情](./user-profile.md) | | 35 | `PATCH /api/v1/user/profile` | Bearer | [详情](./user-profile.md) |
| 36 | `POST /api/v1/user/avatar` | Bearer | [详情](./user-avatar.md) | | 36 | `POST /api/v1/user/avatar` | Bearer | [详情](./user-avatar.md) |
+36
View File
@@ -0,0 +1,36 @@
# POST /api/v1/ad/ecpm-report — 上报本次广告展示的 eCPM(内部收益统计)
> 所属:Ad 组(前缀 `/api/v1/ad` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
## 入参
请求体:`EcpmReportIn`
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `ad_type` | str | 是 | 广告类型:`reward_video`(激励视频) / `draw`(Draw 信息流) 等 |
| `ecpm` | str | 是 | 穿山甲 `getShowEcpm().getEcpm()` 原始字符串,**单位待确认(分/元)**,原样上报 |
| `adn` | str\|null | 否 | 实际投放 ADN(`getSdkName`),如 `pangle` |
| `slot_id` | str\|null | 否 | 实际展示代码位(底层 mediation rit,非客户端配置位) |
`user_id` 不在 body 里——由 JWT 取(Bearer),防伪造。
## 出参
响应 `200`:`EcpmReportOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `ok` | bool | 落库即 `true` |
## 说明
客户端在广告**展示后**(`onAdShow``getShowEcpm()`)调用,把本次展示的 eCPM 落库做**内部收益统计/对账**。
- **best-effort**:客户端 fire-and-forget,丢一两条不影响业务;穿山甲后台报表是结算权威兜底。
- 落 `ad_ecpm_record` 表,`report_date` 用北京时间当天,供「按用户/按天聚合」对账。
- **与发奖是两条独立流**:发奖走 [ad-pangle-callback](./ad-pangle-callback.md)(穿山甲 S2S,有 `trans_id`、无 ecpm),本接口客户端上报(有 ecpm、无 `trans_id`)。两者无公共键,**不逐条一一对应**,只做按用户/按天聚合口径。
- eCPM 是**每千次展示预估**,单条展示预估收益 ≈ `ecpm / 1000`,且为客户端预估口径,非最终结算。
- ⚠️ eCPM **单位(分/元)截至 2026-05-31 未最终确认**,故 `ecpm_raw` 原样存字符串;确认后再加一列解析好的数值。
- 限流:同 IP 120 次/分钟。
## 相关
- [ad-pangle-callback](./ad-pangle-callback.md) — 穿山甲 S2S 发奖回调
- [ad-reward-status](./ad-reward-status.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)。
+37
View File
@@ -0,0 +1,37 @@
# 傻瓜比价 App 后端 — 数据库表文档(索引)
> 数据库:SQLite 起步(`data/app.db`),生产可切 PostgreSQL(改 `DATABASE_URL`)。
> ORM:SQLAlchemy 2.0(`app/models/`),迁移:Alembic(`alembic/versions/`,`render_as_batch` 兼容 SQLite)。
> 金额字段一律存**整数**:金币=个数,现金=**分**(`*_cents`)。时间列 `DateTime(timezone=True)`
> 最后更新:2026-05-31
---
## 表总览
| 表 | 用途 | 模型 | 关联模块 | 文档 |
|---|---|---|---|---|
| `user` | 用户(登录主体) | `models/user.py` | 登录/鉴权 | [详情](./user.md) |
| `coin_account` | 金币+现金余额快照(一用户一行) | `models/wallet.py` | 钱包 | [详情](./coin_account.md) |
| `coin_transaction` | 金币流水账本 | `models/wallet.py` | 钱包 | [详情](./coin_transaction.md) |
| `cash_transaction` | 现金流水账本(分) | `models/wallet.py` | 钱包/提现 | [详情](./cash_transaction.md) |
| `withdraw_order` | 提现单(现金→微信零钱) | `models/wallet.py` | 提现 | [详情](./withdraw_order.md) |
| `signin_record` | 签到记录(7 天循环) | `models/signin.py` | 签到 | [详情](./signin_record.md) |
| `user_task` | 一次性任务领取去重 | `models/task.py` | 任务 | [详情](./user_task.md) |
| `savings_record` | 省钱记录(profile 省钱战绩源) | `models/savings.py` | 省钱 | [详情](./savings_record.md) |
| `ad_reward_record` | 看激励视频发奖记录(S2S 回调) | `models/ad_reward.py` | 看广告发奖 | [详情](./ad_reward_record.md) |
| `ad_ecpm_record` | 广告展示 eCPM 上报(收益对账) | `models/ad_ecpm.py` | 看广告 | [详情](./ad_ecpm_record.md) |
| `feedback` | 用户帮助与反馈 | `models/feedback.py` | 反馈 | [详情](./feedback.md) |
| `comparison_record` | 比价记录(每次比价完整明细) | `models/comparison.py` | 比价记录 | [详情](./comparison_record.md) |
| `comparison_milestone_claim` | 比价战绩里程碑领取记录 | `models/comparison_milestone.py` | 比价记录/福利 | [详情](./comparison_milestone_claim.md) |
---
## 通用约定
- **主键**:`id` Integer autoincrement(`coin_account` 例外:`user_id` 既是主键也是外键,一用户一行)。
- **外键**:所有用户维度表 `user_id``user.id`,且建 index。
- **金额**:整数;金币计数,现金/价格存「分」(`*_cents`)。
- **时间**:`created_at` 等用 `DateTime(timezone=True)` + `server_default=func.now()`;业务"今天"按**北京时间**(见 `core/rewards.cn_today`),跨天计数用「日期串」列(如 `reward_date`/`report_date`)等值查,不在 SQL 里做跨时区 date 比较(SQLite 不可靠)。
- **JSON 列**:用 `JSON().with_variant(JSONB(), "postgresql")` —— PG 上 JSONB(可建 GIN 索引),SQLite 退化为通用 JSON(否则 `create_all` 编译报错)。
- **迁移**:改表必写 alembic 迁移并保持单 head;改表/建表同时更新本目录对应文档(一表一文件)。
+26
View File
@@ -0,0 +1,26 @@
# ad_ecpm_record — 广告展示 eCPM 上报(收益对账)
> 模型 `app/models/ad_ecpm.py` | 关联接口 [ad-ecpm-report](../api/ad-ecpm-report.md) | [← 表索引](./README.md)
每条 = 客户端一次广告展示(`onAdShow`)后读到的 eCPM。与发奖记录 `ad_reward_record` 是**两条独立数据流**(发奖走 S2S 有 trans_id 无 ecpm;eCPM 走客户端有 ecpm 无 trans_id),无公共键,只用于**按用户/按天聚合**收益对账,不做逐条精确关联。穿山甲后台报表才是结算权威。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `ad_type` | String(32) | NOT NULL | 广告类型:`reward_video`(激励视频)/ `draw`(Draw 信息流)等 |
| `adn` | String(32) | nullable | 实际投放 ADN(`getSdkName`,如 pangle/gdt) |
| `slot_id` | String(64) | nullable | 实际展示代码位(底层 mediation rit) |
| `ecpm_raw` | String(32) | NOT NULL | 客户端上报的 eCPM 原始串(单位待确认,原样存) |
| `report_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它做按天聚合 |
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
## 索引与约束
- PK: `id`;index: `user_id``report_date``created_at`
## 关系
- `user_id``user.id`(多对一)
## 说明
- ⚠️ `ecpm_raw` 单位(分/元)截至 2026-05-31 未最终确认;确认后再加一列解析好的数值,在此之前对账按"待定单位"处理。
+28
View File
@@ -0,0 +1,28 @@
# ad_reward_record — 看激励视频发奖记录(S2S 回调)
> 模型 `app/models/ad_reward.py` | 关联接口 [ad-pangle-callback](../api/ad-pangle-callback.md) / [ad-reward-status](../api/ad-reward-status.md) | [← 表索引](./README.md)
每条 = 穿山甲一次发奖回调。`trans_id` 唯一做幂等键(穿山甲会重试,同号只发一次)。`reward_date`(北京时间日期串)给"每日上限"计数用,按日期串等值查。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `trans_id` | String(64) | UNIQUE, index, NOT NULL | 穿山甲交易号(幂等键) |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户(回调 media_extra 带回) |
| `coin` | Integer | NOT NULL, default 0 | 实发金币(capped 时为 0) |
| `status` | String(16) | NOT NULL, default `granted` | `granted`(已发)/ `capped`(当日超限未发) |
| `reward_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它统计当日次数 |
| `reward_name` | String(64) | nullable | 穿山甲上报奖励名(参考,不作发奖依据) |
| `raw` | String(1024) | nullable | 回调原始参数(审计排查) |
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
## 索引与约束
- PK: `id`;UNIQUE + index: `trans_id`;index: `user_id``reward_date``created_at`
## 关系
- `user_id``user.id`(多对一)
## 说明
- 三道闸:验签不过 403 → `trans_id` 唯一幂等(并发 catch IntegrityError)→ 当日次数 ≥ `DAILY_AD_REWARD_LIMIT``capped` 不发币。
- 发币复用 `grant_coins(biz_type='ad_reward', ref_id=trans_id)`
+26
View File
@@ -0,0 +1,26 @@
# cash_transaction — 现金流水账本(分)
> 模型 `app/models/wallet.py` | 关联接口 [wallet-cash-transactions](../api/wallet-cash-transactions.md) | [← 表索引](./README.md)
现金每次变动一笔流水(单位:分)。金币兑现金、提现、提现退款都记这里。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `amount_cents` | Integer | NOT NULL | 正=入账(兑入),负=出账(提现) |
| `balance_after_cents` | Integer | NOT NULL | 本笔变动后现金余额(分) |
| `biz_type` | String(32) | NOT NULL | `exchange_in`(兑入)/ `withdraw`(提现出账)/ `withdraw_refund`(提现退回) |
| `ref_id` | String(64) | nullable | 关联业务 id(如提现 `out_bill_no`) |
| `remark` | String(128) | nullable | 用户可见备注(如"提现未成功,金额已退回") |
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
## 索引与约束
- PK: `id`;index: `user_id``created_at`
## 关系
- `user_id``user.id`(多对一)
## 说明
- 提现失败/取消退款写 `withdraw_refund`(+X);退款流水 `remark` 是用户可见文案,技术原因记在 `withdraw_order.fail_reason`
+24
View File
@@ -0,0 +1,24 @@
# coin_account — 金币 + 现金余额快照
> 模型 `app/models/wallet.py` | 关联接口 [wallet-account](../api/wallet-account.md) | [← 表索引](./README.md)
一个用户一行的余额快照,供读取展示用;每次余额变动都另写流水(`coin_transaction` / `cash_transaction`)并记 `balance_after`,出问题逐笔回溯。`user_id` 既是主键也是外键。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `user_id` | Integer | PK, FK→user.id | 用户(一用户一行) |
| `coin_balance` | Integer | NOT NULL, default 0 | 当前金币余额 |
| `cash_balance_cents` | Integer | NOT NULL, default 0 | 当前现金余额(分) |
| `total_coin_earned` | Integer | NOT NULL, default 0 | 累计赚取金币(只增不减) |
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 最后更新时间 |
## 索引与约束
- PK: `user_id`(同时是 FK→user.id)
## 关系
- `user_id``user.id`(一对一)
## 说明
- **唯一发金币入口** `wallet.grant_coins` 更新本表余额快照 + 写 `coin_transaction`,不 commit,由调用方同事务提交。
- 提现扣现金用带条件 `UPDATE ... WHERE cash_balance_cents >= amount` 原子扣减,防并发超额。
+27
View File
@@ -0,0 +1,27 @@
# coin_transaction — 金币流水账本
> 模型 `app/models/wallet.py` | 关联接口 [wallet-coin-transactions](../api/wallet-coin-transactions.md) | [← 表索引](./README.md)
金币每次变动一笔流水,记变动后余额,用于对账与「金币明细」展示。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `amount` | Integer | NOT NULL | 正=入账(赚),负=出账(花/兑换) |
| `balance_after` | Integer | NOT NULL | 本笔变动后金币余额(对账用) |
| `biz_type` | String(32) | NOT NULL | 业务类型:`signin` / `task_<key>` / `exchange_out` / `ad_reward` / `compare_milestone` … |
| `ref_id` | String(64) | nullable | 关联业务 id(签到日期 / 任务 key / trans_id / 里程碑序号等) |
| `remark` | String(128) | nullable | 备注 |
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
## 索引与约束
- PK: `id`;index: `user_id``created_at`
## 关系
- `user_id``user.id`(多对一)
## 说明
- 明细接口按 `id` 倒序游标分页。
- 各 `biz_type` 取值由各业务写入(签到/任务/兑换/看广告/比价里程碑),无独立枚举约束,靠写入方约定。
@@ -0,0 +1,26 @@
# comparison_milestone_claim — 比价战绩里程碑领取记录
> 模型 `app/models/comparison_milestone.py` | 关联接口 [compare-milestones](../api/compare-milestones.md) / [compare-milestone-claim](../api/compare-milestone-claim.md) | [← 表索引](./README.md)
「记录比价战绩」每档(第 1~6 次)只能领一次,领取后写一行;解锁进度由 `comparison_record``status='success'` 的条数决定,本表只记"哪几档已领"。仿 `user_task` 的一次性领取模型。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `milestone` | Integer | NOT NULL | 档位序号(1-based),见 `rewards.RECORD_MILESTONES` |
| `coin_awarded` | Integer | NOT NULL, default 0 | 该档发放金币 |
| `claimed_at` | DateTime(tz) | server_default now() | 领取时间 |
## 索引与约束
- PK: `id`;index: `user_id`
- UNIQUE(`user_id`, `milestone`) = `uq_compare_milestone_user`(防同档重复领)
## 关系
- `user_id``user.id`(多对一)
- 解锁进度依赖 `comparison_record`(`status='success'` 计数),本表不存进度本身。
## 说明
- 发金币走 `wallet.grant_coins(biz_type='compare_milestone', ref_id=str(milestone))`,与本表写入同事务。
- 领取校验:档位越界 404、未解锁(成功数 < milestone)409、已领 409。
+47
View File
@@ -0,0 +1,47 @@
# comparison_record — 比价记录(每次比价完整明细)
> 模型 `app/models/comparison.py` | 关联接口 [compare-record-report](../api/compare-record-report.md) / [compare-records](../api/compare-records.md) / [compare-record-detail](../api/compare-record-detail.md) | [← 表索引](./README.md)
每完成一次比价(外卖/电商/领券),客户端 done 帧后用带 JWT 通道上报一条。「我的比价记录」页数据源,也是比价战绩里程碑解锁进度的计数源(`status='success'` 条数)。
> 与 `savings_record` 的区别:本表是「每一次**比价行为**的完整明细」(不省钱、甚至失败也记);`savings_record` 是「真正**下单成交**省了多少」。两表独立、互不喂数据。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `device_id` | String(64) | nullable | 设备号 |
| `business_type` | String(16) | NOT NULL, default `food`, index | `food`(当前唯一接通)/ `ecom` / `coupon` |
| `trace_id` | String(64) | NOT NULL | pricebot 侧 trace_id(关联调试落盘 + 幂等键) |
| `source_platform_id` | String(32) | nullable | 源平台代号 |
| `source_platform_name` | String(32) | nullable | 源平台中文名 |
| `source_package` | String(128) | nullable | 源平台 Android 包名 |
| `source_price_cents` | Integer | nullable | 源平台到手价(分) |
| `best_platform_id` | String(32) | nullable | 最优平台代号(= rank=1) |
| `best_platform_name` | String(32) | nullable | 最优平台中文名 |
| `best_price_cents` | Integer | nullable | 最优价(分) |
| `saved_amount_cents` | Integer | nullable | 源价 最优价(可 0/负) |
| `is_source_best` | Boolean | nullable | 源平台就是最便宜(= 没省到) |
| `store_name` | String(128) | nullable | 店铺名 |
| `total_dish_count` | Integer | nullable | 菜品总数 |
| `skipped_dish_count` | Integer | nullable | 跳过(没找到)菜品数 |
| `status` | String(16) | NOT NULL, default `success` | `success`(拿到有效对比)/ `failed`(出错/没采到目标价) |
| `information` | String(256) | nullable | done 帧文案;成功=摘要,失败=具体原因(前端失败时当原因展示) |
| `items` | JSON(PG: JSONB) | NOT NULL, default [] | 下单菜品 `[{name, qty, specs?}]` |
| `comparison_results` | JSON(PG: JSONB) | NOT NULL, default [] | 逐平台对比 `[{platform_id,platform_name,package,price(元),is_source,rank}]` |
| `skipped_dish_names` | JSON(PG: JSONB) | NOT NULL, default [] | 被跳过的菜名 |
| `raw_payload` | JSON(PG: JSONB) | nullable | 客户端原始上报(calibration + done.params 全量) |
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
## 索引与约束
- PK: `id`;index: `user_id``business_type``created_at`
- UNIQUE(`user_id`, `trace_id`) = `uq_comparison_user_trace`(同次比价重试/重复上报幂等覆盖)
## 关系
- `user_id``user.id`(多对一)
- 被 `comparison_milestone_claim` 间接依赖:里程碑解锁进度 = 本表 `status='success'` 条数。
## 说明
- `best_*` / `saved_amount_cents` / `is_source_best` / `status``repositories/comparison.py:_derive``comparison_results` 派生,客户端不用自己算。
- 4 个 JSON 列用 `JSON().with_variant(JSONB(),"postgresql")`(SQLite 退化 JSON)。金额结构化列存「分」,`comparison_results.price` 原样存「元」。
+25
View File
@@ -0,0 +1,25 @@
# feedback — 用户帮助与反馈
> 模型 `app/models/feedback.py` | 关联接口 [feedback](../api/feedback.md) | [← 表索引](./README.md)
每条 = 用户一次反馈提交。`content``contact` 必填,`images` 为可选截图 URL 列表。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 提交用户 |
| `content` | Text | NOT NULL | 反馈正文 |
| `contact` | String(128) | NOT NULL | 联系方式(微信/QQ/手机,便于回访) |
| `images` | JSON | nullable | 截图 URL 列表(相对路径 `/media/feedback/...`);无图为 NULL |
| `status` | String(16) | NOT NULL, default `new` | `new`(待处理)/ `handled`(已处理) |
| `created_at` | DateTime(tz) | server_default now(), index | 提交时间 |
## 索引与约束
- PK: `id`;index: `user_id``created_at`
## 关系
- `user_id``user.id`(多对一)
## 说明
- `images` 用通用 `JSON`(本表未用 JSONB variant);截图先经 `/media/feedback/` 上传拿到相对路径再随反馈提交。
+31
View File
@@ -0,0 +1,31 @@
# savings_record — 省钱记录(profile 省钱战绩源)
> 模型 `app/models/savings.py` | 关联接口 [savings-summary](../api/savings-summary.md) / [savings-battle](../api/savings-battle.md) / [savings-records](../api/savings-records.md) | [← 表索引](./README.md)
profile 页「累计帮你省了 / 省钱战绩 / 省钱明细」的唯一数据源:**真正下单成交后**省了多少记一行。
> ⚠️ **当前为 demo 假数据**:`crud/savings.py:ensure_seeded` 按 user_id 幂等灌 ~23 条(`source='demo'`),聚合(SUM/分组/连续天数)是生产级真实计算。真数据要靠**"用户真下单"信号**(app 目前无:AI 比价止于结算页/结果展示,付款用户手动)——**不是**把比价记录 `comparison_record` 喂过来(那是"比价行为",这是"成交省钱",两个维度)。详见 [[project_shaguabijia_app_server]]。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `order_amount_cents` | Integer | NOT NULL | 订单到手价(分) |
| `saved_amount_cents` | Integer | NOT NULL | 本单省下(分,可为 0) |
| `platform` | String(32) | nullable | 下单平台(美团外卖/淘宝闪购/京东外卖) |
| `title` | String(128) | nullable | 标题 |
| `shop_name` | String(128) | nullable | 店铺名 |
| `dishes` | JSON(PG: JSONB) | NOT NULL, default [] | 菜名列表(前 2 道展示,其余"还有 N 道") |
| `source` | String(16) | NOT NULL, default `compare` | 来源:`demo`(演示)/ `compare`(真实下单上报,待启用) |
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
## 索引与约束
- PK: `id`;index: `user_id``created_at`
## 关系
- `user_id``user.id`(多对一)
## 说明
- `dishes``JSON().with_variant(JSONB(),"postgresql")`(SQLite 退化 JSON)。
- `beat_percent`(超过百分之多少用户)按各用户累计省下金额做真实分位;为有可比人群造了 5 个种子用户(`register_channel='seed'`)。
+26
View File
@@ -0,0 +1,26 @@
# signin_record — 签到记录(7 天循环)
> 模型 `app/models/signin.py` | 关联接口 [signin-status](../api/signin-status.md) / [signin-do](../api/signin-do.md) | [← 表索引](./README.md)
每次签到一行,`(user_id, signin_date)` 唯一,天然防一天签两次。`cycle_day`(1..7)决定发多少金币(`rewards.SIGNIN_REWARDS`),断签重置回 1;`streak` 是连续签到天数(不封顶)用于展示。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `signin_date` | Date | NOT NULL | 签到日期(北京时间 date) |
| `cycle_day` | Integer | NOT NULL | 7 天循环里今天第几档(1..7),决定发币 |
| `streak` | Integer | NOT NULL | 连续签到天数(不封顶) |
| `coin_awarded` | Integer | NOT NULL | 本次发放金币 |
| `created_at` | DateTime(tz) | server_default now() | 时间 |
## 索引与约束
- PK: `id`;index: `user_id`
- UNIQUE(`user_id`, `signin_date`) = `uq_signin_user_date`(防一天签两次)
## 关系
- `user_id``user.id`(多对一)
## 说明
- 签到"今天"按北京时间 `cn_today()`(`CN_TZ=UTC+8`);发币与写本表记录同事务(`grant_coins(biz_type='signin', ref_id=日期)`)。
+32
View File
@@ -0,0 +1,32 @@
# user — 用户(登录主体)
> 模型 `app/models/user.py` | 关联接口 [auth-me](../api/auth-me.md) 等 auth 组 | [← 表索引](./README.md)
登录主体。极光一键登录与短信登录都映射到同一行,以 `phone` 唯一索引;注册即登录(phone 不存在则 insert,存在则更新 `last_login_at`)。后续加微信/Apple 登录新增 oauth_account 表,本表不动。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | 用户主键 |
| `phone` | String(20) | UNIQUE, index, NOT NULL | 手机号(登录主键;注销后置 `deleted_<id>` 释放唯一约束) |
| `register_channel` | String(20) | NOT NULL, default `jverify` | 注册渠道:`jverify` / `sms` |
| `nickname` | String(64) | nullable | 通用昵称(用户改资料设) |
| `avatar_url` | String(512) | nullable | 通用头像相对 URL(`/media/avatars/...`) |
| `wechat_openid` | String(64) | UNIQUE, index, nullable | 微信 openid(绑定后存,提现转账用);一微信一账号 |
| `wechat_nickname` | String(64) | nullable | 微信昵称(绑定时拉,展示在提现绑定卡) |
| `wechat_avatar_url` | String(512) | nullable | 微信头像 URL |
| `status` | String(20) | NOT NULL, default `active` | `active` / `disabled` / `deleted` |
| `created_at` | DateTime(tz) | server_default now() | 注册时间 |
| `last_login_at` | DateTime(tz) | default utcnow(应用层) | 最近登录时间 |
## 索引与约束
- PK: `id`
- UNIQUE + index: `phone`
- UNIQUE + index: `wechat_openid`(允许多个 NULL = 多个未绑定用户)
## 关系
- 被引用方:`coin_account` / `coin_transaction` / `cash_transaction` / `withdraw_order` / `signin_record` / `user_task` / `savings_record` / `ad_reward_record` / `ad_ecpm_record` / `feedback` / `comparison_record` / `comparison_milestone_claim``user_id` 均 → `user.id`
## 说明
- `nickname/avatar_url`(通用)与 `wechat_nickname/wechat_avatar_url`(微信)**分开存,不互相覆盖**。
- 注销账号:phone 改占位串、status=deleted,不物理删行(保留外键完整性)。
+26
View File
@@ -0,0 +1,26 @@
# user_task — 一次性任务领取去重
> 模型 `app/models/task.py` | 关联接口 [tasks-list](../api/tasks-list.md) / [tasks-claim](../api/tasks-claim.md) | [← 表索引](./README.md)
像"打开消息提醒"这类只能领一次的任务,完成后写一行,`(user_id, task_key)` 唯一防重复领奖。可循环领取的任务(签到)不走这张表,有专表 `signin_record`
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `task_key` | String(48) | NOT NULL | 任务标识,见 `rewards.TASK_REWARDS`(如 `enable_notification`) |
| `status` | String(16) | NOT NULL, default `completed` | 任务状态 |
| `coin_awarded` | Integer | NOT NULL, default 0 | 该任务发放金币 |
| `completed_at` | DateTime(tz) | server_default now() | 完成/领取时间 |
## 索引与约束
- PK: `id`;index: `user_id`
- UNIQUE(`user_id`, `task_key`) = `uq_task_user_key`(防重复领)
## 关系
- `user_id``user.id`(多对一)
## 说明
- 领取:写本表 + `grant_coins(biz_type='task_<key>', ref_id=task_key)` 同事务;重复领抛 409,未知 key 抛 404。
- 比价战绩里程碑虽是"领一次"模型但**不复用本表**,另有 `comparison_milestone_claim`(因 task_key 是固定字典,里程碑是按次数解锁的序号)。
+30
View File
@@ -0,0 +1,30 @@
# withdraw_order — 提现单(现金 → 微信零钱)
> 模型 `app/models/wallet.py` | 关联接口 [wallet-withdraw](../api/wallet-withdraw.md) / [wallet-withdraw-status](../api/wallet-withdraw-status.md) | [← 表索引](./README.md)
提现状态机:`pending → success / failed`。扣现金 + 写 `cash_transaction(withdraw)` + 建本单在同一事务;失败/取消时退回现金并写 `cash_transaction(withdraw_refund)``wechat_state` 存微信侧原始状态,`status` 是归一化后的三态。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `out_bill_no` | String(64) | UNIQUE, index, NOT NULL | 商户单号(幂等键 + 微信查单);客户端可传,不传则服务端生成 |
| `amount_cents` | Integer | NOT NULL | 提现金额(分) |
| `status` | String(16) | NOT NULL, default `pending` | 归一化状态:`pending` / `success` / `failed` |
| `wechat_state` | String(32) | nullable | 微信原始状态:`WAIT_USER_CONFIRM` / `SUCCESS` / `FAIL` / `CANCELLED` … |
| `transfer_bill_no` | String(64) | nullable | 微信转账单号 |
| `package_info` | String(512) | nullable | 待用户确认时返回 App 拉起确认页用 |
| `fail_reason` | String(256) | nullable | 失败技术原因(不外露,用户只看流水 remark) |
| `created_at` | DateTime(tz) | server_default now(), index | 发起时间 |
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 更新时间 |
## 索引与约束
- PK: `id`;UNIQUE + index: `out_bill_no`;index: `user_id``created_at`
## 关系
- `user_id``user.id`(多对一)
## 说明
- 资金安全:原子扣款 + `out_bill_no` 幂等 + 模糊失败先查单再决定(绝不盲目退款)+ 孤儿单对账 `reconcile_pending_withdraws`。详见 [[project_shaguabijia_app_server]] 提现段。
- `WITHDRAW_MIN_CENTS=10`(0.1 元,微信地板)。
+6 -1
View File
@@ -61,6 +61,7 @@ app/
│ ├── feedback.py # 帮助与反馈 1 端点(提交反馈含截图) │ ├── feedback.py # 帮助与反馈 1 端点(提交反馈含截图)
│ ├── coupon.py # 领券透传 /coupon/step(转发 pricebot,MVP 不鉴权) │ ├── coupon.py # 领券透传 /coupon/step(转发 pricebot,MVP 不鉴权)
│ ├── compare.py # 外卖比价透传 /intent/recognize + /price/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 凭证降级返空 │ ├── meituan.py # 美团 3 端点 + feed 拼接(_interleave / _TOPIC_ROUNDS),未配 MT_CPS 凭证降级返空
│ ├── wallet.py # 钱包/提现 11 端点(余额/流水/兑换/绑微信/提现/查单) │ ├── wallet.py # 钱包/提现 11 端点(余额/流水/兑换/绑微信/提现/查单)
│ ├── signin.py # 签到 2 端点(状态 / 执行签到) │ ├── signin.py # 签到 2 端点(状态 / 执行签到)
@@ -73,6 +74,7 @@ app/
│ ├── feedback.py # 反馈出参(请求是 multipart,在 router 直接校验) │ ├── feedback.py # 反馈出参(请求是 multipart,在 router 直接校验)
│ ├── meituan.py │ ├── meituan.py
│ ├── welfare.py # 钱包/签到/任务/省钱 收发模型 │ ├── welfare.py # 钱包/签到/任务/省钱 收发模型
│ ├── compare_record.py # 比价记录上报/列表/详情 收发模型(字段对齐 pricebot calibration + done.params)
│ └── ad.py # 看广告发奖收发模型 │ └── ad.py # 看广告发奖收发模型
├── integrations/ # 外部服务/SDK 客户端(重逻辑:签名/加解密/外部 HTTP) ├── integrations/ # 外部服务/SDK 客户端(重逻辑:签名/加解密/外部 HTTP)
│ ├── jiguang.py # 极光 REST 验 token + RSA 解密(多 padding 试错) │ ├── jiguang.py # 极光 REST 验 token + RSA 解密(多 padding 试错)
@@ -94,6 +96,7 @@ app/
│ ├── signin.py # 签到记录 / 连续天数 / 档位 │ ├── signin.py # 签到记录 / 连续天数 / 档位
│ ├── task.py # 一次性任务领取 │ ├── task.py # 一次性任务领取
│ ├── savings.py # 省钱汇总 / 战绩 / 明细 │ ├── savings.py # 省钱汇总 / 战绩 / 明细
│ ├── comparison.py # 比价记录 upsert(user_id+trace_id 幂等)+ best/saved/status 派生 + 分页
│ └── ad_reward.py # 看广告发奖(按 trans_id 幂等 + 每日上限 + 本轮冷却派生) │ └── ad_reward.py # 看广告发奖(按 trans_id 幂等 + 每日上限 + 本轮冷却派生)
├── models/ # ORM 表结构 ├── models/ # ORM 表结构
│ ├── user.py # user(含微信 openid/nickname/avatar) │ ├── user.py # user(含微信 openid/nickname/avatar)
@@ -102,6 +105,7 @@ app/
│ ├── signin.py # 签到记录 │ ├── signin.py # 签到记录
│ ├── task.py # 任务领取记录 │ ├── task.py # 任务领取记录
│ ├── savings.py # 省钱明细 / 店铺菜品 / dishes(PG 上 JSONB) │ ├── savings.py # 省钱明细 / 店铺菜品 / dishes(PG 上 JSONB)
│ ├── comparison.py # 比价记录(完整明细;含 4 个 JSON(B) 列 + raw_payload;独立于 savings)
│ └── ad_reward.py # 看广告发奖记录 │ └── ad_reward.py # 看广告发奖记录
└── db/ └── db/
├── base.py # DeclarativeBase ├── base.py # DeclarativeBase
@@ -218,7 +222,7 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
## 7. 数据模型 ## 7. 数据模型
10 张业务表 + `alembic_version` 框架表。生产 PG / 开发可回退 SQLite。 业务表(下表)+ `alembic_version` 框架表。生产 PG / 开发可回退 SQLite。
| 表 | models 文件 | 说明 | | 表 | models 文件 | 说明 |
|---|---|---| |---|---|---|
@@ -231,6 +235,7 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
| `signin_record` | `models/signin.py` | 签到记录(每日一行,索引 `(user_id, signin_date)`) | | `signin_record` | `models/signin.py` | 签到记录(每日一行,索引 `(user_id, signin_date)`) |
| `user_task` | `models/task.py` | 一次性任务领取记录 | | `user_task` | `models/task.py` | 一次性任务领取记录 |
| `savings_record` | `models/savings.py` | 省钱明细(`dishes` 在 PG 上为 JSONB,SQLite 上是 TEXT) | | `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` 唯一,幂等) | | `ad_reward_record` | `models/ad_reward.py` | 看广告发奖(`trans_id` 唯一,幂等) |
**user 表字段速查**: **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),建时同样先不加鉴权。 - **现状**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` 仍照传(后端按设备串领券队列够用)。 - **代价(为什么记这笔账)**:不验 JWT → 转发时 server 拿不到 `user_id` → agent 行为只能绑到 `device_id`(设备级),**采集不到"哪个用户领了/买了什么"的用户级画像**。而精准人群画像、私域分群运营是商业模式的核心资产,靠的就是这份用户级行为数据。`device_id` 仍照传(后端按设备串领券队列够用)。
- **待补**:① agent 接口加 JWT 鉴权;② 建立 `device_id ↔ user_id` 绑定(登录后上报一次即可);③ 领券/比价记录按 user 维度落库。 - **待补**:① agent 接口加 JWT 鉴权;② 建立 `device_id ↔ user_id` 绑定(登录后上报一次即可);③ ~~领券/比价记录按 user 维度落库~~**比价记录已落地**(见文末「已解决」):走**独立的鉴权端点** `POST /api/v1/compare/record`,客户端在 done 帧后用带 JWT 的通道上报、不依赖透传链路。**所以透传端点本身仍不鉴权、本条 P1 主诉求(给透传链路补 JWT + device↔user 绑定)未解**;领券记录尚未落库
- **连带**:补鉴权后,客户端引擎 `ApiClient` 要接回 JWT(复用 app 现有 `AuthInterceptor` 思路:注入带鉴权头的 OkHttpClient)。 - **连带**:补鉴权后,客户端引擎 `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)。 - ✅ **领券链路接通**(阶段 1):首页「去领取」→ `CouponPromptDialog` → 权限检查 → `startCouponClaim` → 循环 `/api/v1/coupon/step`;Running 悬浮窗换皮(ComposeView 承载 `CouponProgressPanel`/AgentFloat,`OverlayLifecycleOwner` 撑 Compose)。
- ✅ **coupon/step 去鉴权**:去掉 `CurrentUser`,MVP 不鉴权(device_id 透传)。 - ✅ **coupon/step 去鉴权**:去掉 `CurrentUser`,MVP 不鉴权(device_id 透传)。
- ✅ **ApiClient 改址**:`BuildConfig.BASE_URL` + `/api/v1/` 前缀。 - ✅ **ApiClient 改址**:`BuildConfig.BASE_URL` + `/api/v1/` 前缀。
+1 -1
View File
@@ -84,7 +84,7 @@ def test_callback_uses_reward_amount(client) -> None:
assert _callback(client, _signed(uid, "ra_ok", reward_amount="250")).status_code == 200 assert _callback(client, _signed(uid, "ra_ok", reward_amount="250")).status_code == 200
assert _coin_balance(client, token) == 250 assert _coin_balance(client, token) == 250
# ≤0 / 非数字 → 回退 AD_REWARD_COIN(累加 100) # ≤0 / 非数字 → 回退 AD_REWARD_COIN(累加 AD_REWARD_COIN,当前 666)
assert _callback(client, _signed(uid, "ra_zero", reward_amount="0")).status_code == 200 assert _callback(client, _signed(uid, "ra_zero", reward_amount="0")).status_code == 200
assert _coin_balance(client, token) == 250 + AD_REWARD_COIN assert _coin_balance(client, token) == 250 + AD_REWARD_COIN
+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