diff --git a/alembic/versions/ad_ecpm_record.py b/alembic/versions/ad_ecpm_record.py new file mode 100644 index 0000000..a810674 --- /dev/null +++ b/alembic/versions/ad_ecpm_record.py @@ -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') diff --git a/alembic/versions/comparison_milestone_claim_table.py b/alembic/versions/comparison_milestone_claim_table.py new file mode 100644 index 0000000..a33731f --- /dev/null +++ b/alembic/versions/comparison_milestone_claim_table.py @@ -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') diff --git a/alembic/versions/comparison_record_information.py b/alembic/versions/comparison_record_information.py new file mode 100644 index 0000000..9216bc8 --- /dev/null +++ b/alembic/versions/comparison_record_information.py @@ -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') diff --git a/alembic/versions/comparison_record_table.py b/alembic/versions/comparison_record_table.py new file mode 100644 index 0000000..5410b76 --- /dev/null +++ b/alembic/versions/comparison_record_table.py @@ -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') diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index e4441d3..51a0732 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -19,8 +19,15 @@ from app.core import rewards from app.core.config import settings from app.integrations import pangle 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.schemas.ad import AdRewardStatusOut, PangleCallbackOut, TestGrantOut +from app.schemas.ad import ( + AdRewardStatusOut, + EcpmReportIn, + EcpmReportOut, + PangleCallbackOut, + TestGrantOut, +) 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( "/test-grant", response_model=TestGrantOut, diff --git a/app/api/v1/compare_milestone.py b/app/api/v1/compare_milestone.py new file mode 100644 index 0000000..18c3bc0 --- /dev/null +++ b/app/api/v1/compare_milestone.py @@ -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 + ) diff --git a/app/api/v1/compare_record.py b/app/api/v1/compare_record.py new file mode 100644 index 0000000..40100b5 --- /dev/null +++ b/app/api/v1/compare_record.py @@ -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) diff --git a/app/core/ad_cooldown.py b/app/core/ad_cooldown.py new file mode 100644 index 0000000..80e26d5 --- /dev/null +++ b/app/core/ad_cooldown.py @@ -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) diff --git a/app/core/rewards.py b/app/core/rewards.py index 4b2b6b2..80998a4 100644 --- a/app/core/rewards.py +++ b/app/core/rewards.py @@ -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 服务端回调发奖)===== -# 看完一个激励视频发的金币(100 金币 ≈¥0.01,汇率 10000 金币=1 元)。 +# 看完一个激励视频发的金币(666 金币 ≈¥0.0666,汇率 10000 金币=1 元)。 # 作用:① 回调缺/坏 reward_amount 时的回退值;② 客户端进度接口展示的"单次预告金币"; # ③ test-grant 本地联调的发奖额。 # 真实发放以穿山甲回调带回的 reward_amount 为准(见 resolve_ad_reward_coin),后台应把 -# 代码位"奖励数量"配成与本值一致(=100),保证"广告内展示 / 进度预告 / 实际到账"三者一致。 -AD_REWARD_COIN: int = 100 +# 代码位"奖励数量"配成与本值一致(=666),保证"广告内展示 / 进度预告 / 实际到账"三者一致。 +AD_REWARD_COIN: int = 666 # 单次发奖金币上限:夹紧穿山甲回调里异常的 reward_amount(如后台多打一个 0),防刷爆余额。 MAX_AD_REWARD_COIN: int = 1000 # 每用户每日发奖次数上限,防刷 + 控成本。 diff --git a/app/main.py b/app/main.py index b3b0e6c..6d6a238 100644 --- a/app/main.py +++ b/app/main.py @@ -17,6 +17,8 @@ from fastapi.staticfiles import StaticFiles from app.api.v1.ad import router as ad_router from app.api.v1.auth import router as auth_router from app.api.v1.compare import router as compare_router +from app.api.v1.compare_milestone import router as compare_milestone_router +from app.api.v1.compare_record import router as compare_record_router from app.api.v1.coupon import router as coupon_router from app.api.v1.feedback import router as feedback_router from app.api.v1.meituan import router as meituan_router @@ -75,6 +77,8 @@ app.include_router(user_router) app.include_router(feedback_router) app.include_router(coupon_router) app.include_router(compare_router) +app.include_router(compare_record_router) +app.include_router(compare_milestone_router) app.include_router(meituan_router) app.include_router(wallet_router) app.include_router(signin_router) diff --git a/app/models/__init__.py b/app/models/__init__.py index 047a2a6..b9fd0ac 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,5 +1,8 @@ """所有 ORM model 必须在这里 import 一次,Alembic / metadata 才能扫到。""" +from app.models.ad_ecpm import AdEcpmRecord # noqa: F401 from app.models.ad_reward import AdRewardRecord # noqa: F401 +from app.models.comparison import ComparisonRecord # noqa: F401 +from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401 from app.models.feedback import Feedback # noqa: F401 from app.models.savings import SavingsRecord # noqa: F401 from app.models.signin import SigninRecord # noqa: F401 diff --git a/app/models/ad_ecpm.py b/app/models/ad_ecpm.py new file mode 100644 index 0000000..e3a5c14 --- /dev/null +++ b/app/models/ad_ecpm.py @@ -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"" + ) diff --git a/app/models/comparison.py b/app/models/comparison.py new file mode 100644 index 0000000..22dab56 --- /dev/null +++ b/app/models/comparison.py @@ -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"" + ) diff --git a/app/models/comparison_milestone.py b/app/models/comparison_milestone.py new file mode 100644 index 0000000..b49d900 --- /dev/null +++ b/app/models/comparison_milestone.py @@ -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"" diff --git a/app/repositories/ad_ecpm.py b/app/repositories/ad_ecpm.py new file mode 100644 index 0000000..8af5f14 --- /dev/null +++ b/app/repositories/ad_ecpm.py @@ -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() diff --git a/app/repositories/ad_reward.py b/app/repositories/ad_reward.py index a901443..d42aab5 100644 --- a/app/repositories/ad_reward.py +++ b/app/repositories/ad_reward.py @@ -10,17 +10,16 @@ """ from __future__ import annotations -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session +from app.core.ad_cooldown import compute_cooldown from app.core.rewards import ( AD_REWARD_COIN, DAILY_AD_REWARD_LIMIT, - VIDEO_ROUND_COOLDOWN_SECONDS, - VIDEO_ROUND_REQUIRED_COUNT, cn_today, ) from app.repositories import wallet as crud_wallet @@ -110,26 +109,19 @@ def _commit_record(db: Session, rec: AdRewardRecord, trans_id: str) -> AdRewardR return rec -def _last_completed_round_end_at( - db: Session, user_id: int, reward_date: str, round_count: int -) -> datetime | None: - """当日 granted 记录中**最近一个已完成轮**末尾那次的 created_at。 - - 思路:把当日 granted 按时间倒序排,跳过当前未完成轮的 round_count 条,下一条 - 即"上一轮最后一次"。round_count==0 且 used>=N 时跳 0 条直接取最近一条。 - used list[datetime]: + """当日 status=granted 记录的 created_at,按时间倒序(最新在前)——冷却策略的输入数据。""" + return list( + db.execute( + select(AdRewardRecord.created_at) + .where( + AdRewardRecord.user_id == user_id, + AdRewardRecord.reward_date == reward_date, + AdRewardRecord.status == "granted", + ) + .order_by(AdRewardRecord.created_at.desc()) + ).scalars() + ) def today_status( @@ -138,21 +130,16 @@ def today_status( """客户端查"今日看广告发奖"进度。 返回 (今日已发次数, 每日上限, 单次金币, 本轮已看次数, 本轮冷却结束时间(UTC))。 - - round_count = used % VIDEO_ROUND_REQUIRED_COUNT,展示用(0..N-1) - - cooldown_until 计算:取最近一个已完成轮末尾的 created_at + 10 min;若仍 > now 则返回, - 否则返回 None。冷却 = UX 约束(客户端 CTA 倒计时不可点),后端发奖逻辑不受影响。 + 本函数只**取数据**(今日 granted 的 created_at 倒序),把"本轮已看几次 + 冷却到几点"的 + **策略判断**委托给 [app.core.ad_cooldown.compute_cooldown](纯函数)——换冷却策略只动那个模块。 """ today = cn_today().isoformat() - used = _granted_today(db, user_id, today) - round_count = used % VIDEO_ROUND_REQUIRED_COUNT - cooldown_until: datetime | None = None - if used >= VIDEO_ROUND_REQUIRED_COUNT: - last_end = _last_completed_round_end_at(db, user_id, today, round_count) - if last_end is not None: - # SQLAlchemy 在 SQLite 上拿到的 created_at 可能是 naive,统一按 UTC 解读再比较 - if last_end.tzinfo is None: - last_end = last_end.replace(tzinfo=timezone.utc) - cd_end = last_end + timedelta(seconds=VIDEO_ROUND_COOLDOWN_SECONDS) - if cd_end > datetime.now(timezone.utc): - cooldown_until = cd_end - return used, DAILY_AD_REWARD_LIMIT, AD_REWARD_COIN, round_count, cooldown_until + granted_desc = _granted_times_today_desc(db, user_id, today) + state = compute_cooldown(granted_desc, datetime.now(timezone.utc)) + return ( + len(granted_desc), + DAILY_AD_REWARD_LIMIT, + AD_REWARD_COIN, + state.round_count, + state.cooldown_until, + ) diff --git a/app/repositories/comparison.py b/app/repositories/comparison.py new file mode 100644 index 0000000..3a5e299 --- /dev/null +++ b/app/repositories/comparison.py @@ -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() diff --git a/app/repositories/comparison_milestone.py b/app/repositories/comparison_milestone.py new file mode 100644 index 0000000..88f8152 --- /dev/null +++ b/app/repositories/comparison_milestone.py @@ -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 diff --git a/app/schemas/ad.py b/app/schemas/ad.py index 47635e9..7ed42d0 100644 --- a/app/schemas/ad.py +++ b/app/schemas/ad.py @@ -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): """[仅本地联调]模拟发奖结果。带上今日进度,客户端可直接据此刷新展示。""" diff --git a/app/schemas/compare_record.py b/app/schemas/compare_record.py new file mode 100644 index 0000000..e6e142d --- /dev/null +++ b/app/schemas/compare_record.py @@ -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="领奖后金币余额") diff --git a/docs/api/README.md b/docs/api/README.md index d1f4d08..b391f42 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -3,7 +3,7 @@ > Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770` > 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case** > 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer ` -> 最后更新:2026-05-31 +> 最后更新:2026-05-31(+ 比价战绩里程碑 12d/12e) > 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。 --- @@ -26,6 +26,13 @@ | **比价透传**(前缀 `/api/v1`,外卖 MVP;与 `coupon/step` 同为透传 pricebot-backend) ||| | 12 | `POST /api/v1/intent/recognize` | 无 | [详情](./compare-intent-recognize.md) | | 13 | `POST /api/v1/price/step` | 无 | [详情](./compare-price-step.md) | +| **比价记录**(前缀 `/api/v1/compare`;按用户落库,**鉴权**,区别于上面不鉴权的透传) ||| +| 12a | `POST /api/v1/compare/record` | Bearer | [详情](./compare-record-report.md) | +| 12b | `GET /api/v1/compare/records` | Bearer | [详情](./compare-records.md) | +| 12c | `GET /api/v1/compare/records/{id}` | Bearer | [详情](./compare-record-detail.md) | +| **比价战绩里程碑**(前缀 `/api/v1/compare`;福利页「记录比价战绩」,按成功比价数解锁逐档发金币) ||| +| 12d | `GET /api/v1/compare/milestones` | Bearer | [详情](./compare-milestones.md) | +| 12e | `POST /api/v1/compare/milestones/{milestone}/claim` | Bearer | [详情](./compare-milestone-claim.md) | | **钱包 / 我的资产**(前缀 `/api/v1/wallet`) ||| | 14 | `GET /api/v1/wallet/account` | Bearer | [详情](./wallet-account.md) | | 15 | `GET /api/v1/wallet/coin-transactions` | Bearer | [详情](./wallet-coin-transactions.md) | @@ -52,6 +59,7 @@ | 32 | `GET /api/v1/ad/pangle-callback` | 验签 | [详情](./ad-pangle-callback.md) | | 33 | `GET /api/v1/ad/reward-status` | Bearer | [详情](./ad-reward-status.md) | | 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad-test-grant.md) | +| 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad-ecpm-report.md) | | **用户资料**(前缀 `/api/v1/user`) ||| | 35 | `PATCH /api/v1/user/profile` | Bearer | [详情](./user-profile.md) | | 36 | `POST /api/v1/user/avatar` | Bearer | [详情](./user-avatar.md) | diff --git a/docs/api/ad-ecpm-report.md b/docs/api/ad-ecpm-report.md new file mode 100644 index 0000000..d58afee --- /dev/null +++ b/docs/api/ad-ecpm-report.md @@ -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) — 今日看广告发奖进度 diff --git a/docs/api/compare-milestone-claim.md b/docs/api/compare-milestone-claim.md new file mode 100644 index 0000000..40472ad --- /dev/null +++ b/docs/api/compare-milestone-claim.md @@ -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 且**不会重复发金币**(唯一约束 + 领取前查重)。 diff --git a/docs/api/compare-milestones.md b/docs/api/compare-milestones.md new file mode 100644 index 0000000..075a444 --- /dev/null +++ b/docs/api/compare-milestones.md @@ -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。 diff --git a/docs/api/compare-record-detail.md b/docs/api/compare-record-detail.md new file mode 100644 index 0000000..eed1bcd --- /dev/null +++ b/docs/api/compare-record-detail.md @@ -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 属于他人」两种情况——不区分以免泄露他人记录是否存在。 diff --git a/docs/api/compare-record-report.md b/docs/api/compare-record-report.md new file mode 100644 index 0000000..0dc0e04 --- /dev/null +++ b/docs/api/compare-record-report.md @@ -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 }` + +**Result(comparison_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 展示由前端过滤。 diff --git a/docs/api/compare-records.md b/docs/api/compare-records.md new file mode 100644 index 0000000..70d3889 --- /dev/null +++ b/docs/api/compare-records.md @@ -0,0 +1,47 @@ +# GET /api/v1/compare/records — 比价记录列表(游标分页) + +> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](./README.md) + +「我的比价记录」列表页数据源。按 `id` 倒序(最新在前)。 + +## 入参(query) + +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `limit` | int | ❌ | 20 | 1–100 | +| `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)。 diff --git a/docs/database/README.md b/docs/database/README.md new file mode 100644 index 0000000..818a70b --- /dev/null +++ b/docs/database/README.md @@ -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;改表/建表同时更新本目录对应文档(一表一文件)。 diff --git a/docs/database/ad_ecpm_record.md b/docs/database/ad_ecpm_record.md new file mode 100644 index 0000000..4464e36 --- /dev/null +++ b/docs/database/ad_ecpm_record.md @@ -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 未最终确认;确认后再加一列解析好的数值,在此之前对账按"待定单位"处理。 diff --git a/docs/database/ad_reward_record.md b/docs/database/ad_reward_record.md new file mode 100644 index 0000000..14e0fff --- /dev/null +++ b/docs/database/ad_reward_record.md @@ -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)`。 diff --git a/docs/database/cash_transaction.md b/docs/database/cash_transaction.md new file mode 100644 index 0000000..87e096d --- /dev/null +++ b/docs/database/cash_transaction.md @@ -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`。 diff --git a/docs/database/coin_account.md b/docs/database/coin_account.md new file mode 100644 index 0000000..c039328 --- /dev/null +++ b/docs/database/coin_account.md @@ -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` 原子扣减,防并发超额。 diff --git a/docs/database/coin_transaction.md b/docs/database/coin_transaction.md new file mode 100644 index 0000000..3ffb76a --- /dev/null +++ b/docs/database/coin_transaction.md @@ -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_` / `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` 取值由各业务写入(签到/任务/兑换/看广告/比价里程碑),无独立枚举约束,靠写入方约定。 diff --git a/docs/database/comparison_milestone_claim.md b/docs/database/comparison_milestone_claim.md new file mode 100644 index 0000000..7d626c3 --- /dev/null +++ b/docs/database/comparison_milestone_claim.md @@ -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。 diff --git a/docs/database/comparison_record.md b/docs/database/comparison_record.md new file mode 100644 index 0000000..c9f3c4c --- /dev/null +++ b/docs/database/comparison_record.md @@ -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` 原样存「元」。 diff --git a/docs/database/feedback.md b/docs/database/feedback.md new file mode 100644 index 0000000..6df7219 --- /dev/null +++ b/docs/database/feedback.md @@ -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/` 上传拿到相对路径再随反馈提交。 diff --git a/docs/database/savings_record.md b/docs/database/savings_record.md new file mode 100644 index 0000000..a30bbfd --- /dev/null +++ b/docs/database/savings_record.md @@ -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'`)。 diff --git a/docs/database/signin_record.md b/docs/database/signin_record.md new file mode 100644 index 0000000..abe7a96 --- /dev/null +++ b/docs/database/signin_record.md @@ -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=日期)`)。 diff --git a/docs/database/user.md b/docs/database/user.md new file mode 100644 index 0000000..5c8595e --- /dev/null +++ b/docs/database/user.md @@ -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_` 释放唯一约束) | +| `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,不物理删行(保留外键完整性)。 diff --git a/docs/database/user_task.md b/docs/database/user_task.md new file mode 100644 index 0000000..fe4aeb6 --- /dev/null +++ b/docs/database/user_task.md @@ -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_', ref_id=task_key)` 同事务;重复领抛 409,未知 key 抛 404。 +- 比价战绩里程碑虽是"领一次"模型但**不复用本表**,另有 `comparison_milestone_claim`(因 task_key 是固定字典,里程碑是按次数解锁的序号)。 diff --git a/docs/database/withdraw_order.md b/docs/database/withdraw_order.md new file mode 100644 index 0000000..7ab6eba --- /dev/null +++ b/docs/database/withdraw_order.md @@ -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 元,微信地板)。 diff --git a/docs/后端技术实现.md b/docs/后端技术实现.md index 66ba37a..a91aaef 100644 --- a/docs/后端技术实现.md +++ b/docs/后端技术实现.md @@ -61,6 +61,7 @@ app/ │ ├── feedback.py # 帮助与反馈 1 端点(提交反馈含截图) │ ├── coupon.py # 领券透传 /coupon/step(转发 pricebot,MVP 不鉴权) │ ├── compare.py # 外卖比价透传 /intent/recognize + /price/step(转发 pricebot,MVP 不鉴权) +│ ├── compare_record.py# 比价记录 3 端点(上报 /compare/record + 列表 /compare/records + 详情;鉴权,区别于上面透传) │ ├── meituan.py # 美团 3 端点 + feed 拼接(_interleave / _TOPIC_ROUNDS),未配 MT_CPS 凭证降级返空 │ ├── wallet.py # 钱包/提现 11 端点(余额/流水/兑换/绑微信/提现/查单) │ ├── signin.py # 签到 2 端点(状态 / 执行签到) @@ -73,6 +74,7 @@ app/ │ ├── feedback.py # 反馈出参(请求是 multipart,在 router 直接校验) │ ├── meituan.py │ ├── welfare.py # 钱包/签到/任务/省钱 收发模型 +│ ├── compare_record.py # 比价记录上报/列表/详情 收发模型(字段对齐 pricebot calibration + done.params) │ └── ad.py # 看广告发奖收发模型 ├── integrations/ # 外部服务/SDK 客户端(重逻辑:签名/加解密/外部 HTTP) │ ├── jiguang.py # 极光 REST 验 token + RSA 解密(多 padding 试错) @@ -94,6 +96,7 @@ app/ │ ├── signin.py # 签到记录 / 连续天数 / 档位 │ ├── task.py # 一次性任务领取 │ ├── savings.py # 省钱汇总 / 战绩 / 明细 +│ ├── comparison.py # 比价记录 upsert(user_id+trace_id 幂等)+ best/saved/status 派生 + 分页 │ └── ad_reward.py # 看广告发奖(按 trans_id 幂等 + 每日上限 + 本轮冷却派生) ├── models/ # ORM 表结构 │ ├── user.py # user(含微信 openid/nickname/avatar) @@ -102,6 +105,7 @@ app/ │ ├── signin.py # 签到记录 │ ├── task.py # 任务领取记录 │ ├── savings.py # 省钱明细 / 店铺菜品 / dishes(PG 上 JSONB) +│ ├── comparison.py # 比价记录(完整明细;含 4 个 JSON(B) 列 + raw_payload;独立于 savings) │ └── ad_reward.py # 看广告发奖记录 └── db/ ├── base.py # DeclarativeBase @@ -218,7 +222,7 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert ## 7. 数据模型 -10 张业务表 + `alembic_version` 框架表。生产 PG / 开发可回退 SQLite。 +业务表(下表)+ `alembic_version` 框架表。生产 PG / 开发可回退 SQLite。 | 表 | models 文件 | 说明 | |---|---|---| @@ -231,6 +235,7 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert | `signin_record` | `models/signin.py` | 签到记录(每日一行,索引 `(user_id, signin_date)`) | | `user_task` | `models/task.py` | 一次性任务领取记录 | | `savings_record` | `models/savings.py` | 省钱明细(`dishes` 在 PG 上为 JSONB,SQLite 上是 TEXT) | +| `comparison_record` | `models/comparison.py` | 比价记录(每次比价完整明细;`(user_id, trace_id)` 唯一幂等;4 个 JSON(B) 列含 `raw_payload`;独立于 savings) | | `ad_reward_record` | `models/ad_reward.py` | 看广告发奖(`trans_id` 唯一,幂等) | **user 表字段速查**: diff --git a/docs/待办与技术债.md b/docs/待办与技术债.md index 0ee7dd9..a456d49 100644 --- a/docs/待办与技术债.md +++ b/docs/待办与技术债.md @@ -11,8 +11,8 @@ - **现状**:agent 系列接口 MVP 阶段**全部不鉴权**。领券 `coupon/step` **已落地不鉴权**(✅ 已去掉 `CurrentUser` 依赖);比价 4 个(`intent/recognize`、`price/step`、`ecom/intent/recognize`、`ecom/step`)端点尚未建(见 P2),建时同样先不加鉴权。 - **代价(为什么记这笔账)**:不验 JWT → 转发时 server 拿不到 `user_id` → agent 行为只能绑到 `device_id`(设备级),**采集不到"哪个用户领了/买了什么"的用户级画像**。而精准人群画像、私域分群运营是商业模式的核心资产,靠的就是这份用户级行为数据。`device_id` 仍照传(后端按设备串领券队列够用)。 -- **待补**:① agent 接口加 JWT 鉴权;② 建立 `device_id ↔ user_id` 绑定(登录后上报一次即可);③ 领券/比价记录按 user 维度落库。 -- **连带**:补鉴权后,客户端引擎 `ApiClient` 要接回 JWT(复用 app 现有 `AuthInterceptor` 思路:注入带鉴权头的 OkHttpClient)。 +- **待补**:① agent 接口加 JWT 鉴权;② 建立 `device_id ↔ user_id` 绑定(登录后上报一次即可);③ ~~领券/比价记录按 user 维度落库~~ → **比价记录已落地**(见文末「已解决」):走**独立的鉴权端点** `POST /api/v1/compare/record`,客户端在 done 帧后用带 JWT 的通道上报、不依赖透传链路。**所以透传端点本身仍不鉴权、本条 P1 主诉求(给透传链路补 JWT + device↔user 绑定)未解**;领券记录尚未落库。 +- **连带**:补鉴权后,客户端引擎 `ApiClient` 要接回 JWT(复用 app 现有 `AuthInterceptor` 思路:注入带鉴权头的 OkHttpClient)。比价记录上报已**先行复用**这套(`authedClient` 挂 `AuthInterceptor`+`RefreshAuthenticator`),可作透传链路补鉴权的参考样板。 --- @@ -87,6 +87,10 @@ ## 已解决 +- ✅ **比价记录落库(server + client,2026-05-31)**:每次比价 done 后客户端上报、按 user 落库,作「我的比价记录」数据源 + 用户级画像沉淀。 + - **server**(本仓):新表 `comparison_record`(独立于 `savings_record`;结构化列 + `items`/`comparison_results`/`skipped_dish_names`/`raw_payload` 四个 JSON(B) 列)+ 3 个**鉴权**端点 `POST /api/v1/compare/record`(`(user_id,trace_id)` 幂等 upsert,best/saved/is_source_best/status 服务端从 comparison_results 派生)/ `GET /api/v1/compare/records`(游标分页)/ `GET /records/{id}`(含 raw_payload);`models/comparison.py` + `repositories/comparison.py` + `schemas/compare_record.py` + `api/v1/compare_record.py` + 迁移 `comparison_record_table`(head `b2c3d4e5f6a7`)+ `tests/test_compare_record.py`(8 例全过)+ `docs/api/compare-record-*.md`。 + - **client**(android 仓):`PriceBotService.runTask()` 比价 done 后(`lastDoneParams!=null`,成功/引擎失败都报)用独立 IO 协程尽力上报;`Protocol.CompareRecordRequest.fromComparison()` 从 calibration+done.params 零翻译组装;`ApiClient.reportCompareRecord()` 走新建的 `authedClient`(复用 `AuthInterceptor`+`RefreshAuthenticator`,自动 Bearer+401 刷新)。领券不报(非价格对比,本期范围只外卖)。 + - **未做**:UI(「我的比价记录」页)等原型;真机端到端待验。 - ✅ **领券链路接通**(阶段 1):首页「去领取」→ `CouponPromptDialog` → 权限检查 → `startCouponClaim` → 循环 `/api/v1/coupon/step`;Running 悬浮窗换皮(ComposeView 承载 `CouponProgressPanel`/AgentFloat,`OverlayLifecycleOwner` 撑 Compose)。 - ✅ **coupon/step 去鉴权**:去掉 `CurrentUser`,MVP 不鉴权(device_id 透传)。 - ✅ **ApiClient 改址**:`BuildConfig.BASE_URL` + `/api/v1/` 前缀。 diff --git a/tests/test_ad_reward.py b/tests/test_ad_reward.py index 516df34..a7f2b65 100644 --- a/tests/test_ad_reward.py +++ b/tests/test_ad_reward.py @@ -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 _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 _coin_balance(client, token) == 250 + AD_REWARD_COIN diff --git a/tests/test_compare_milestone.py b/tests/test_compare_milestone.py new file mode 100644 index 0000000..bf3f2c9 --- /dev/null +++ b/tests/test_compare_milestone.py @@ -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 diff --git a/tests/test_compare_record.py b/tests/test_compare_record.py new file mode 100644 index 0000000..90d5153 --- /dev/null +++ b/tests/test_compare_record.py @@ -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