Compare commits

...

17 Commits

Author SHA1 Message Date
OuYingJun1024 cfac7b64ef fix(welfare): review 修复 — 里程碑"暂不发金币"文档同步 + 消息提醒奖励降为 1000
- comparison_milestone: 删 record_milestone_reward 死导入(ruff F401), 模块 docstring 同步"暂不发金币"
- 里程碑文档群对齐实现: compare-milestones / comparison_milestone_claim / MilestoneStateOut.coin 描述都标注暂不真发金币
- models/comparison: comparison_results JSONB 形状注释补 coupon_saved
- enable_notification 奖励 10000→1000(产品定, =¥0.1); test_exchange_flow 改 grant_coins 直接供款(不再靠该任务=兑换下限)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:17:26 +08:00
OuYingJun1024 1c61f231a0 feat(compare): comparison_results 增加 coupon_saved(平台红包优惠额)
- schema ComparisonResultIn 显式声明 coupon_saved(否则 model_dump 丢字段)
- 上报→落库→读出贯通, 加 passthrough 测试(目标带值/源平台 null)
- 更新 api/database 文档

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 22:05:00 +08:00
OuYingJun1024 dd3129116c Merge branch 'main' into feat/signin-remark-and-milestone-no-coin 2026-06-02 15:07:24 +08:00
OuYingJun1024 518f8c8b92 Merge branch 'main' of https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server 2026-06-02 15:07:23 +08:00
marco e4588303fb feat(compare): 加 /api/v1/intent/step 透传到 pricebot(淘宝多帧意图识别)
配套 pricebot-backend 的 /api/intent/step(淘宝源 Phase 1 多帧意图识别: 展开 + 滚动
采集 → LLM 提取)。沿用 _passthrough 壳: 不鉴权、原样转发, 客户端循环调用直到 done
(done 帧顶层带 result + calibration)。目前仅淘宝源走这条, 其它源走单次 /intent/recognize。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 14:40:19 +08:00
OuYingJun1024 78cd4c2696 feat(wallet): 流水 remark 语义化 + 比价里程碑暂不发金币
- 签到发奖 remark='每日签到 第N天';看视频发奖 remark='看视频奖励金币'(原'看广告奖励')

- 比价战绩里程碑领取暂不真发金币(产品定,后续删该功能):仍记领取/标记已领,但不调 grant_coins、余额不变、coin_awarded 恒 0

- 同步 test_compare_milestone(领取后余额不变/coin_awarded==0) + docs/api/compare-milestone-claim

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 14:23:48 +08:00
OuYingJun1024 9521cd96ce chore(alembic): merge comparison_milestone 和 savings_report_fields 两个 head 2026-06-02 09:53:25 +08:00
ouzhou da7ce69494 feat(ad): eCPM 上报接口 + 冷却策略解耦纯函数 + savings JSONB 跨库修复 (#8)
- 新增 POST /api/v1/ad/ecpm-report:激励视频展示后客户端上报本次 eCPM,落 ad_ecpm_record 做内部收益统计(model/repository/schema/alembic 迁移 + 接口文档)
- 看广告冷却策略抽到 app/core/ad_cooldown.py 纯函数;ad_reward.today_status 只取数据,换策略只改这一处
- savings.dishes 改 JSON().with_variant(JSONB,"postgresql"):SQLite 无 visit_JSONB 会让 create_all 编译崩,修复后测试套件恢复

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #8
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-01 22:23:15 +08:00
zhangxianze 288766443a feat(记账): 比价省钱记账后端 M1(savings_record 唯一真相表 + /order/report + demo 兜底) (#10)
从最新 origin/main 重拉的干净分支,只含本人记账改动,不含 marco 的归因订单(order_record)——后者是另一条未合并分支 feat/user-order-records 的功能,此前被误用 git add -A 和记账混进了 feat/savings-accounting-m1。本分支不带它:savings_record 升级为唯一真相表(新增原价/比价价/支付渠道/源平台/幂等键等列,迁移 savings_report_fields 直接挂 f01db5d77dac、不经 order_record_table);/order/report 改写 savings_record(source='compare'),省额=源平台原价−实付;展示 /api/v1/savings/* 真实优先 demo 兜底。无 order_record 的 model/repo/迁移/路由。alembic 单一 head + pytest 54 passed。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: xianze <ze@192.168.0.138>
Reviewed-on: #10
Co-authored-by: zhangxianze <zhangxianze@wonderable.ai>
Co-committed-by: zhangxianze <zhangxianze@wonderable.ai>
2026-06-01 21:29:52 +08:00
marco a114a0f3f2 docs(integrations/wxpay): 删 code_to_openid 行,跟齐 9d1278c 死代码清理
9d1278c 已删 wxpay.py 的 code_to_openid 函数(能力被 code_to_userinfo 覆盖),
docs 函数表里这一行没跟着删,补齐。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 20:44:46 +08:00
marco 9d1278cb33 chore: 清理两处未使用代码
- app/db/base.py: 删 MappedAsDataclass 的 import (没人继承用)
- app/integrations/wxpay.py: 删 code_to_openid 函数 (code_to_userinfo 已覆盖能力, 18 行死代码)
2026-05-31 17:10:16 +08:00
marco 6c7eaa9734 docs: 跟齐 5 月 PR (#4 user/feedback / #5 ad-cooldown / #6 PG / abd8eab meituan 降级)
整轮 doc 跟齐 2026-05 落地但 doc 没补全/没更新的内容. 按 P0~P3 优先级处理.

P0 - 新 API doc + index 更新:
* docs/api/user-profile.md  (新) PATCH /api/v1/user/profile 改昵称
* docs/api/user-avatar.md   (新) POST /api/v1/user/avatar 上传头像(魔数嗅探/旧文件清理)
* docs/api/user-delete.md   (新) DELETE /api/v1/user 注销账号(软删+匿名化)
* docs/api/feedback.md      (新) POST /api/v1/feedback 提交反馈(含截图)
* docs/api/README.md           加 User/Feedback 组 + /media 静态服务段 +
                                修 UserOut "无接口可改恒为 null" 过时描述

P0 - 美团降级 / 现有 doc 校准:
* docs/api/meituan-coupons.md      加 "未配 MT_CPS 凭证降级返空" 说明
* docs/api/meituan-referral-link.md 同上 + 客户端兜底建议
* docs/api/meituan-feed.md         区分两种空结果路径(未配 vs 调用失败) +
                                    /health.mt_cps_configured 排障入口
* docs/integrations/meituan.md     "未配凭证降级" + "feed 静默吞错" 写进集成层
* ad-reward-status.md              PR #5 commit 已自带 doc 更新, 复核无需再改

P1 - 主文档大改:
* docs/后端技术实现.md
  - §1 能力表加 用户资料/外卖比价/钱包/福利/广告/反馈 (从 3 条扩到 9 条)
  - §1 数据模型纠正 "仅 1 张 user 表" -> 现有 10 张 + alembic_version
  - §2 DB 行 PG 主 / SQLite 兜底 + 加 psycopg3 行
  - §3 目录树补 user.py / feedback.py / media.py / repositories/feedback.py /
        scripts 子项 / docs/postgres-migration.md
  - §7 数据模型重写: 10 张业务表速查表 + alembic 合并迁移 f01db5d77dac 始末
  - §8 部署: 加 init_postgres.py 一句话流程 + checklist 加 "DATABASE_URL 切 PG"
        + "psycopg3 已装" + "MEDIA_ROOT 可写"
  - §10 已知问题: 加 美团降级 / agent 不鉴权 / MEDIA_ROOT serve / init_postgres bug /
        alembic 多 head 风险; 删过时 SQLite 行
  - 接口计数 11 -> 38

* docs/待办与技术债.md
  - 已解决追加: PG 切换 / 外卖比价透传 / 用户资料反馈接口 / 美团降级 /
                看广告 4 态冷却
  - 新增 "P2 DB 与 Alembic 治理" 段: alembic 多 head 团队规约 (含本次撞坑实录) +
    init_postgres.py 已知 5 处小 bug + MEDIA_ROOT serve

P2 - PG 迁移文档互指 + 迁移链补完:
* docs/数据库迁移.md
  - 顶部加 postgres-migration.md / 后端技术实现.md 互指
  - §6 迁移链 9 条 -> 12 条 (含 feedback_table / convert_dishes_jsonb /
        merge_pg_jsonb_and_feedback_heads)
  - §7 踩坑里 "多 head" 一行扩成完整故事 + 修复命令 + 团队预防规约
* docs/postgres-migration.md
  - 顶部加 数据库迁移.md / 后端技术实现.md §10 互指

P3 - 上线清单:
* docs/看广告赚金币上线清单.md
  - 设计要点加 PR #5 4 态 CTA + 后端权威派生说明 +
    VIDEO_ROUND_REQUIRED_COUNT/VIDEO_ROUND_COOLDOWN_SECONDS 常量
  - §E 端到端验收加 本轮冷却 / 弹窗 limit note / 跨设备一致 三项

所有最后更新日期同步到 2026-05-31.
2026-05-31 11:05:10 +08:00
marco c3c64fa06d chore: alembic merge revision + PG 迁移指南文档 (#7)
PostgreSQL 迁移 (#6) 之后两个分支引入了独立 heads (ef96beb47b1e +
d1e2f3a4b5c6), 必须合并成单一 head 才能 alembic upgrade head 顺利跑通。
- alembic/versions/f01db5d77dac_merge_pg_jsonb_and_feedback_heads.py:
  空 op 的 merge revision, 把两个 heads 收敛为 f01db5d77dac
- docs/postgres-migration.md: SQLite → PostgreSQL 迁移指南
  (背景/动机/操作步骤/无真实用户数据假设, 上线前补 pgloader 演练等)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Reviewed-on: #7
2026-05-30 18:13:11 +08:00
marco e69e244de7 Merge pull request 'feat(ad-reward): reward-status 加本轮 3 次冷却字段 (round_count + cooldown_until)' (#5) from feat/welfare-video-round-cooldown into main
Reviewed-on: #5
2026-05-29 15:45:51 +08:00
marco 2ebde935f9 Merge pull request 'feat: SQLite → PostgreSQL 迁移代码改动' (#6) from feat/postgres-migration-and-bootstrap-script into main
Reviewed-on: #6
2026-05-29 15:44:40 +08:00
chenshuobo b3d3fda744 feat: 新增 PG 初始化脚本 scripts/init_postgres.py
新机器一键拉起数据库, 替代手动跑 psql 建用户建库的 6 行命令.

脚本特性:
- 跨平台 Python (Windows/macOS/Linux 通吃, 不依赖 psql CLI 在 PATH 里)
- 直接用 psycopg 连 PG, 不 shell out
- 幂等: 用户已存在则重置密码, 库已存在则跳过, 重复跑不出错
- 业务密码自动 secrets.token_urlsafe(32) 强随机, 不让同事自己想
- 自动从 .env.example 复制出 .env 并写入 DATABASE_URL
- .env 里已有 DATABASE_URL 会询问是否覆盖 (不无脑覆盖)
- 跑完自动 alembic upgrade head, 一条命令到可用状态
- 全交互项支持环境变量提前指定 (PG_SUPER_PASS / APP_DB_PASS 等), CI 友好

同事新机器初始化流程从 6 步简化为 3 步:
  pip install -e .
  python scripts/init_postgres.py    # 唯一手动输入项: postgres 超级用户密码
  ./run.sh

CREATE/ALTER USER 注意点: PG 不支持参数化密码绑定, 用 sql.Literal 内联,
psycopg 自带的转义会处理引号和注入.

docs/数据库迁移.md 拆出 PG / SQLite 两套初始化流程, PG 走脚本,
SQLite 保留旧步骤 (本地临时回退用).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 15:21:04 +08:00
chenshuobo b76e5bd515 feat: SQLite → PostgreSQL 迁移代码改动
按 docs/数据库迁移.md 第 3 节扫雷清单落地代码层改动:

- pyproject.toml: 新增 psycopg[binary]>=3.1 依赖 (psycopg3, SQLAlchemy 2.0 时代默认, 不要再装 psycopg2)

- app/db/session.py: 非 SQLite 自动加连接池参数
  - pool_size=10, max_overflow=20, pool_recycle=3600
  - SQLite 单文件不池化, _is_sqlite 判断分支保留以便本地 dev 临时回退

- app/models/savings.py: dishes 列从 JSON 改为 JSONB
  - PG 上能用 GIN 索引和 jsonb 操作符
  - SQLite 上 JSON 实际是 TEXT, 切到 PG 后用 JSON 类型存的是 json 不是 jsonb

- alembic/versions/ef96beb47b1e_*.py: 新增迁移把 savings_record.dishes 从 json 转 jsonb
  - 用 op.get_bind().dialect.name 判断 PG 才执行 ALTER COLUMN
  - SQLite 上是 no-op (SQLite 没有 jsonb 类型)
  - 旧的 savings_shop_dishes.py 迁移保持 sa.JSON() 不动 (按文档"不改老迁移"原则)

本地切 PG 验证: 10 张表全部建出, dishes 字段为 jsonb 类型,
登录/me/美团 feed 三个真接口全 200, user 数据正确写入 PG.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 15:20:22 +08:00
76 changed files with 3446 additions and 159 deletions
@@ -0,0 +1,26 @@
"""merge comparison and savings heads
Revision ID: 8ac524a8ea02
Revises: d4e5f6a7b8c9, savings_report_fields
Create Date: 2026-06-02 09:53:06.924912
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '8ac524a8ea02'
down_revision: Union[str, Sequence[str], None] = ('d4e5f6a7b8c9', 'savings_report_fields')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
+47
View File
@@ -0,0 +1,47 @@
"""ad_ecpm_record table (广告展示 eCPM 上报记录,内部收益统计/对账)
Revision ID: a1b2c3d4e5f6
Revises: f01db5d77dac
Create Date: 2026-05-31 11:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a1b2c3d4e5f6'
down_revision: Union[str, Sequence[str], None] = 'f01db5d77dac'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'ad_ecpm_record',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('ad_type', sa.String(length=32), nullable=False),
sa.Column('adn', sa.String(length=32), nullable=True),
sa.Column('slot_id', sa.String(length=64), nullable=True),
sa.Column('ecpm_raw', sa.String(length=32), nullable=False),
sa.Column('report_date', sa.String(length=10), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
)
with op.batch_alter_table('ad_ecpm_record', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_ad_ecpm_record_user_id'), ['user_id'], unique=False)
batch_op.create_index(batch_op.f('ix_ad_ecpm_record_report_date'), ['report_date'], unique=False)
batch_op.create_index(batch_op.f('ix_ad_ecpm_record_created_at'), ['created_at'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('ad_ecpm_record', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_ad_ecpm_record_created_at'))
batch_op.drop_index(batch_op.f('ix_ad_ecpm_record_report_date'))
batch_op.drop_index(batch_op.f('ix_ad_ecpm_record_user_id'))
op.drop_table('ad_ecpm_record')
@@ -0,0 +1,41 @@
"""comparison_milestone_claim table (比价战绩里程碑领取记录)
Revision ID: d4e5f6a7b8c9
Revises: c3d4e5f6a7b8
Create Date: 2026-05-31 18:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd4e5f6a7b8c9'
down_revision: Union[str, Sequence[str], None] = 'c3d4e5f6a7b8'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'comparison_milestone_claim',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('milestone', sa.Integer(), nullable=False),
sa.Column('coin_awarded', sa.Integer(), nullable=False),
sa.Column('claimed_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'milestone', name='uq_compare_milestone_user'),
)
with op.batch_alter_table('comparison_milestone_claim', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_comparison_milestone_claim_user_id'), ['user_id'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('comparison_milestone_claim', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_comparison_milestone_claim_user_id'))
op.drop_table('comparison_milestone_claim')
@@ -0,0 +1,28 @@
"""comparison_record.information (done 帧文案/失败原因)
Revision ID: c3d4e5f6a7b8
Revises: b2c3d4e5f6a7
Create Date: 2026-05-31 18:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'c3d4e5f6a7b8'
down_revision: Union[str, Sequence[str], None] = 'b2c3d4e5f6a7'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('comparison_record', schema=None) as batch_op:
batch_op.add_column(sa.Column('information', sa.String(length=256), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('comparison_record', schema=None) as batch_op:
batch_op.drop_column('information')
@@ -0,0 +1,64 @@
"""comparison_record table (比价记录:每次比价完整明细,「我的比价记录」数据源)
Revision ID: b2c3d4e5f6a7
Revises: a1b2c3d4e5f6
Create Date: 2026-05-31 15:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b2c3d4e5f6a7'
down_revision: Union[str, Sequence[str], None] = 'a1b2c3d4e5f6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'comparison_record',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('device_id', sa.String(length=64), nullable=True),
sa.Column('business_type', sa.String(length=16), nullable=False),
sa.Column('trace_id', sa.String(length=64), nullable=False),
sa.Column('source_platform_id', sa.String(length=32), nullable=True),
sa.Column('source_platform_name', sa.String(length=32), nullable=True),
sa.Column('source_package', sa.String(length=128), nullable=True),
sa.Column('source_price_cents', sa.Integer(), nullable=True),
sa.Column('best_platform_id', sa.String(length=32), nullable=True),
sa.Column('best_platform_name', sa.String(length=32), nullable=True),
sa.Column('best_price_cents', sa.Integer(), nullable=True),
sa.Column('saved_amount_cents', sa.Integer(), nullable=True),
sa.Column('is_source_best', sa.Boolean(), nullable=True),
sa.Column('store_name', sa.String(length=128), nullable=True),
sa.Column('total_dish_count', sa.Integer(), nullable=True),
sa.Column('skipped_dish_count', sa.Integer(), nullable=True),
sa.Column('status', sa.String(length=16), nullable=False),
# PG 上为 JSONB,其它(SQLite)为 JSON——与模型层 with_variant 对齐
sa.Column('items', sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), 'postgresql'), nullable=False),
sa.Column('comparison_results', sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), 'postgresql'), nullable=False),
sa.Column('skipped_dish_names', sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), 'postgresql'), nullable=False),
sa.Column('raw_payload', sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), 'postgresql'), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'trace_id', name='uq_comparison_user_trace'),
)
with op.batch_alter_table('comparison_record', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_comparison_record_user_id'), ['user_id'], unique=False)
batch_op.create_index(batch_op.f('ix_comparison_record_business_type'), ['business_type'], unique=False)
batch_op.create_index(batch_op.f('ix_comparison_record_created_at'), ['created_at'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('comparison_record', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_comparison_record_created_at'))
batch_op.drop_index(batch_op.f('ix_comparison_record_business_type'))
batch_op.drop_index(batch_op.f('ix_comparison_record_user_id'))
op.drop_table('comparison_record')
@@ -0,0 +1,36 @@
"""convert savings_record.dishes from json to jsonb
PG only. SQLite 上 JSON/JSONB 都是 TEXT, 此迁移是 no-op。
Revision ID: ef96beb47b1e
Revises: c8d9e0f1a2b3
Create Date: 2026-05-29 10:57:21.471774
"""
from typing import Sequence, Union
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB
revision: str = 'ef96beb47b1e'
down_revision: Union[str, Sequence[str], None] = 'c8d9e0f1a2b3'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
if op.get_bind().dialect.name != "postgresql":
return
op.execute(
"ALTER TABLE savings_record "
"ALTER COLUMN dishes TYPE JSONB USING dishes::JSONB"
)
def downgrade() -> None:
if op.get_bind().dialect.name != "postgresql":
return
op.execute(
"ALTER TABLE savings_record "
"ALTER COLUMN dishes TYPE JSON USING dishes::JSON"
)
@@ -0,0 +1,26 @@
"""merge pg_jsonb and feedback heads
Revision ID: f01db5d77dac
Revises: ef96beb47b1e, d1e2f3a4b5c6
Create Date: 2026-05-29 16:09:58.498935
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'f01db5d77dac'
down_revision: Union[str, Sequence[str], None] = ('ef96beb47b1e', 'd1e2f3a4b5c6')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
+49
View File
@@ -0,0 +1,49 @@
"""savings_record:真实比价上报字段 + (user_id, client_event_id) 幂等唯一约束
把 savings_record 升级为「记账唯一真相表」:真实上报(source='compare')写入
原价/比价价/支付渠道/平台包名/源平台名/源链接/幂等键/device_id;
demo seeder 行(source='demo')这些列均为 NULL。
Revision ID: savings_report_fields
Revises: f01db5d77dac
Create Date: 2026-05-31 19:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'savings_report_fields'
down_revision: Union[str, Sequence[str], None] = 'f01db5d77dac'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('savings_record', schema=None) as batch_op:
batch_op.add_column(sa.Column('original_price_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('compared_price_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('pay_channel', sa.String(length=16), nullable=True))
batch_op.add_column(sa.Column('platform_package', sa.String(length=128), nullable=True))
batch_op.add_column(sa.Column('source_platform_name', sa.String(length=32), nullable=True))
batch_op.add_column(sa.Column('source_deeplink', sa.String(length=512), nullable=True))
batch_op.add_column(sa.Column('client_event_id', sa.String(length=64), nullable=True))
batch_op.add_column(sa.Column('device_id', sa.String(length=128), nullable=True))
# (user_id, client_event_id) 幂等;demo 行 client_event_id 为 NULL 不冲突
batch_op.create_unique_constraint('uq_savings_user_event', ['user_id', 'client_event_id'])
def downgrade() -> None:
with op.batch_alter_table('savings_record', schema=None) as batch_op:
batch_op.drop_constraint('uq_savings_user_event', type_='unique')
batch_op.drop_column('device_id')
batch_op.drop_column('client_event_id')
batch_op.drop_column('source_deeplink')
batch_op.drop_column('source_platform_name')
batch_op.drop_column('platform_package')
batch_op.drop_column('pay_channel')
batch_op.drop_column('compared_price_cents')
batch_op.drop_column('original_price_cents')
+32 -1
View File
@@ -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,
+7
View File
@@ -83,6 +83,13 @@ async def intent_recognize(request: Request) -> dict[str, Any]:
return await _passthrough(request, "/api/intent/recognize")
@router.post("/intent/step", summary="外卖比价 Phase 1 多帧意图识别 (透传到 pricebot, 仅淘宝源)")
async def intent_step(request: Request) -> dict[str, Any]:
# 多帧版意图识别(展开+滚动采集→提取): 循环调用直到 done(done 帧顶层带
# result+calibration)。目前仅淘宝源走这条, 其它源走上面单次 /intent/recognize。
return await _passthrough(request, "/api/intent/step")
@router.post("/price/step", summary="外卖比价 Phase 2 步进 (透传到 pricebot)")
async def price_step(request: Request) -> dict[str, Any]:
return await _passthrough(request, "/api/price/step")
+76
View File
@@ -0,0 +1,76 @@
"""比价战绩里程碑 endpoint(福利页「记录比价战绩」)。
路由前缀 `/api/v1/compare`:
GET /milestones 进度与各档领取状态
POST /milestones/{milestone}/claim 领取某档奖励
**均需鉴权**。解锁进度 = 该用户 status='success' 的 comparison_record 条数;每档领一次。
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, status
from app.api.deps import CurrentUser, DbSession
from app.repositories import comparison_milestone as crud_milestone
from app.schemas.compare_record import (
MilestoneClaimResultOut,
MilestoneStateOut,
MilestoneStatusOut,
)
logger = logging.getLogger("shagua.compare_milestone")
router = APIRouter(prefix="/api/v1/compare", tags=["compare-milestone"])
@router.get(
"/milestones",
response_model=MilestoneStatusOut,
summary="比价战绩里程碑进度",
)
def get_milestones(user: CurrentUser, db: DbSession) -> MilestoneStatusOut:
st = crud_milestone.get_status(db, user.id)
return MilestoneStatusOut(
success_count=st.success_count,
claimable_count=st.claimable_count,
milestones=[
MilestoneStateOut(milestone=m.milestone, coin=m.coin, state=m.state)
for m in st.milestones
],
)
@router.post(
"/milestones/{milestone}/claim",
response_model=MilestoneClaimResultOut,
summary="领取比价战绩里程碑奖励",
)
def claim_milestone(
milestone: int, user: CurrentUser, db: DbSession
) -> MilestoneClaimResultOut:
try:
coin, balance = crud_milestone.claim(db, user.id, milestone)
except crud_milestone.UnknownMilestoneError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="unknown milestone"
) from e
except crud_milestone.MilestoneLockedError as e:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="milestone locked"
) from e
except crud_milestone.AlreadyClaimedError as e:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="milestone already claimed"
) from e
logger.info(
"compare milestone claimed user_id=%d milestone=%d coin=%d",
user.id,
milestone,
coin,
)
return MilestoneClaimResultOut(
milestone=milestone, coin_awarded=coin, coin_balance=balance
)
+86
View File
@@ -0,0 +1,86 @@
"""比价记录 endpoint(「我的比价记录」数据源)。
路由前缀 `/api/v1/compare`:
POST /record 上报一次比价结果(幂等:同 user+trace_id 覆盖)
GET /records 比价记录列表(游标分页)
GET /records/{id} 单条详情(含 raw_payload 全量)
**均需鉴权**(CurrentUser)——与同文件无关的不鉴权透传 `compare.py` 分开:那个是
转发壳(MVP 不鉴权),这里是按用户维度落库的业务接口,必须有 user_id。
注:本轮只做 server 端,客户端(android 仓)在 done 帧后调 POST /record 上报的改动
另起一轮(见 app-server docs/待办与技术债.md P1)。
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Query, status
from app.api.deps import CurrentUser, DbSession
from app.repositories import comparison as crud_compare
from app.schemas.compare_record import (
ComparisonRecordCreatedOut,
ComparisonRecordDetailOut,
ComparisonRecordIn,
ComparisonRecordPage,
ComparisonRecordOut,
)
logger = logging.getLogger("shagua.compare_record")
router = APIRouter(prefix="/api/v1/compare", tags=["compare-record"])
@router.post(
"/record",
response_model=ComparisonRecordCreatedOut,
summary="上报一次比价结果(幂等)",
)
def report_record(
payload: ComparisonRecordIn, user: CurrentUser, db: DbSession
) -> ComparisonRecordCreatedOut:
rec = crud_compare.upsert_record(db, user_id=user.id, payload=payload)
logger.info(
"compare record user=%s trace=%s biz=%s status=%s saved=%s",
user.id,
rec.trace_id,
rec.business_type,
rec.status,
rec.saved_amount_cents,
)
return ComparisonRecordCreatedOut(id=rec.id)
@router.get(
"/records",
response_model=ComparisonRecordPage,
summary="比价记录列表(游标分页)",
)
def list_records(
user: CurrentUser,
db: DbSession,
limit: int = Query(20, ge=1, le=100),
cursor: int | None = Query(None, description="上一页末条 id"),
) -> ComparisonRecordPage:
items, next_cursor = crud_compare.list_records(
db, user.id, limit=limit, cursor=cursor
)
return ComparisonRecordPage(
items=[ComparisonRecordOut.model_validate(it) for it in items],
next_cursor=next_cursor,
)
@router.get(
"/records/{record_id}",
response_model=ComparisonRecordDetailOut,
summary="比价记录详情(含 raw_payload)",
)
def get_record(
record_id: int, user: CurrentUser, db: DbSession
) -> ComparisonRecordDetailOut:
rec = crud_compare.get_record(db, user.id, record_id)
if rec is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="record not found")
return ComparisonRecordDetailOut.model_validate(rec)
+25
View File
@@ -0,0 +1,25 @@
from fastapi import APIRouter
from app.api.deps import CurrentUser, DbSession
from app.repositories import savings as crud_savings
from app.schemas.order import OrderReportOut, OrderReportRequest
router = APIRouter(prefix="/api/v1/order", tags=["order"])
@router.post(
"/report",
response_model=OrderReportOut,
summary="上报归因订单(比价后5分钟内点链接 + 支付金额与比价价相差≤1元)",
)
def report_order(req: OrderReportRequest, user: CurrentUser, db: DbSession) -> OrderReportOut:
# 记账唯一真相表是 savings_record(source='compare')。
rec, duplicated = crud_savings.create_from_report(db, user.id, req)
return OrderReportOut(
id=rec.id,
platform=rec.platform or req.platform,
pay_channel=rec.pay_channel or req.pay_channel,
compared_price_cents=rec.compared_price_cents or 0,
paid_amount_cents=rec.order_amount_cents,
duplicated=duplicated,
)
+54
View File
@@ -0,0 +1,54 @@
"""看激励视频冷却策略 —— 与发奖记录查询解耦的纯计算。
当前策略:**每 N 次一轮,看满一轮后强制冷却若干秒**(N / 秒数 取自 [rewards] 常量)。
[repositories.ad_reward.today_status] 只负责取数据(今日 granted 的 created_at 列表),
"本轮已看几次 + 冷却到几点"的策略判断委托到这里。
⚠️ 这是临时策略,后续要调。换策略(间隔式 / 每日配额式 / 指数退避 …)**只改本文件**,
repository 不碰——这就是把它独立出来的目的。保持 [compute_cooldown] 签名稳定即可。
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from app.core.rewards import VIDEO_ROUND_COOLDOWN_SECONDS, VIDEO_ROUND_REQUIRED_COUNT
@dataclass(frozen=True)
class CooldownState:
"""冷却策略的输出。"""
round_count: int # 本轮已看次数 0..N-1(展示用)
cooldown_until: datetime | None # 本轮冷却结束时间(UTC);None = 不在冷却
def compute_cooldown(
granted_times_desc: list[datetime],
now: datetime,
*,
round_size: int = VIDEO_ROUND_REQUIRED_COUNT,
cooldown_seconds: int = VIDEO_ROUND_COOLDOWN_SECONDS,
) -> CooldownState:
""""每 round_size 次一轮、看满一轮后冷却 cooldown_seconds 秒"算本轮进度 + 冷却结束时间。
:param granted_times_desc: 今日 status=granted 记录的 created_at,**按时间倒序**(最新在前)。
:param now: 当前时间(UTC,带 tzinfo),用于判断冷却是否已过。
:param round_size / cooldown_seconds: 策略参数,默认取 rewards 常量,可注入便于测试/调参。
纯函数,不碰 DB。冷却派生算法:把今日 granted 倒序,跳过当前未完成轮的 round_count 条,
下一条即"上一轮最后一次"的时间,+ cooldown_seconds 仍 > now 则在冷却中。
SQLite 上 created_at 可能是 naive,按 UTC 解读再比较。
"""
used = len(granted_times_desc)
round_count = used % round_size
cooldown_until: datetime | None = None
if used >= round_size:
# round_count 必 < round_size <= used,索引合法
last_round_end = granted_times_desc[round_count]
if last_round_end.tzinfo is None:
last_round_end = last_round_end.replace(tzinfo=timezone.utc)
cd_end = last_round_end + timedelta(seconds=cooldown_seconds)
if cd_end > now:
cooldown_until = cd_end
return CooldownState(round_count=round_count, cooldown_until=cooldown_until)
+19 -4
View File
@@ -53,18 +53,33 @@ WITHDRAW_MAX_CENTS: int = 5_000_000 # 5 万元
TASK_ENABLE_NOTIFICATION = "enable_notification"
# task_key -> 奖励金币
# 打开消息提醒: 1000 金币(=¥0.1, 客户端原型展示口径; 量级与签到/里程碑相称)。
# 注意: 已不再 = 兑换下限(MIN_EXCHANGE_COIN=10000), test_exchange_flow 改走 grant_coins 直接供款。
TASK_REWARDS: dict[str, int] = {
TASK_ENABLE_NOTIFICATION: 10000,
TASK_ENABLE_NOTIFICATION: 1000,
}
# ===== 比价战绩里程碑(累计成功比价 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
# 每用户每日发奖次数上限,防刷 + 控成本。
+1 -1
View File
@@ -4,7 +4,7 @@
"""
from __future__ import annotations
from sqlalchemy.orm import DeclarativeBase, MappedAsDataclass
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
+14 -8
View File
@@ -27,19 +27,25 @@ def _ensure_sqlite_dir(url: str) -> None:
_ensure_sqlite_dir(settings.DATABASE_URL)
_is_sqlite = settings.DATABASE_URL.startswith("sqlite")
# SQLite 跨线程访问要 check_same_thread=False;PG/MySQL 不需要这个参数
_connect_args: dict = {}
if settings.DATABASE_URL.startswith("sqlite"):
if _is_sqlite:
_connect_args["check_same_thread"] = False
engine = create_engine(
settings.DATABASE_URL,
connect_args=_connect_args,
_engine_kwargs: dict = {
"connect_args": _connect_args,
# echo 在 dev 下打 SQL,生产关掉
echo=settings.APP_DEBUG and not settings.is_prod,
future=True,
pool_pre_ping=True,
)
"echo": settings.APP_DEBUG and not settings.is_prod,
"future": True,
"pool_pre_ping": True,
}
# SQLite 用单文件不需要池;PG/MySQL 必须显式池化 + recycle 防 idle 断连
if not _is_sqlite:
_engine_kwargs.update(pool_size=10, max_overflow=20, pool_recycle=3600)
engine = create_engine(settings.DATABASE_URL, **_engine_kwargs)
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False, expire_on_commit=False)
-18
View File
@@ -162,24 +162,6 @@ def cancel_transfer(out_bill_no: str) -> dict:
return {"status_code": resp.status_code, "data": resp.json()}
def code_to_openid(code: str) -> str:
"""用微信授权 code 换 openid(开放平台移动应用 sns/oauth2)。失败抛 ValueError。"""
resp = httpx.get(
"https://api.weixin.qq.com/sns/oauth2/access_token",
params={
"appid": settings.WECHAT_APP_ID,
"secret": settings.WECHAT_APP_SECRET,
"code": code,
"grant_type": "authorization_code",
},
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
)
data = resp.json()
if "openid" not in data:
raise ValueError(f"微信授权失败: {data.get('errmsg', data)}")
return data["openid"]
def code_to_userinfo(code: str) -> dict:
"""code 换 access_token+openid,再拉 sns/userinfo 取昵称头像。
返回 {openid, nickname, avatar_url, raw}。失败抛 ValueError。
+6
View File
@@ -17,9 +17,12 @@ 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
from app.api.v1.order import router as order_router
from app.api.v1.savings import router as savings_router
from app.api.v1.signin import router as signin_router
from app.api.v1.tasks import router as tasks_router
@@ -74,12 +77,15 @@ 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)
app.include_router(tasks_router)
app.include_router(savings_router)
app.include_router(ad_router)
app.include_router(order_router)
# 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。
_media_root = Path(settings.MEDIA_ROOT)
+3
View File
@@ -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
+49
View File
@@ -0,0 +1,49 @@
"""广告展示 eCPM 上报记录(内部收益统计/对账)。
每条 = 客户端一次广告展示(`onAdShow`)后读到的 eCPM 信息。和发奖记录
[ad_reward.AdRewardRecord] 是**两条独立的数据流**:
- 发奖走穿山甲 S2S 回调(后端 → 有 trans_id、无 ecpm);
- eCPM 走客户端上报(客户端 → 有 ecpm、无 trans_id)。
两者没有公共键,无法逐条一一对应,所以本表用于**按用户/按天聚合收益**口径的对账,
不做"这条发奖 = 这条 ecpm"的精确关联。穿山甲后台报表才是结算权威,本表是细粒度补充。
⚠️ `ecpm_raw` 原样存客户端上报的字符串——eCPM 单位(分 / 元)截至 2026-05-31 尚未最终确认,
确认后再加一列解析好的数值;在此之前对账按"待定单位"处理。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class AdEcpmRecord(Base):
__tablename__ = "ad_ecpm_record"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False
)
# 广告类型:reward_video(激励视频) / draw(Draw 信息流) 等;不强行统一代码位,各类型各自上报
ad_type: Mapped[str] = mapped_column(String(32), nullable=False)
# 实际投放的 ADN(穿山甲 getShowEcpm().getSdkName(),如 pangle / gdt)
adn: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 实际展示用的代码位(底层 mediation rit,非客户端配置位)
slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 客户端上报的 eCPM 原始字符串(单位待确认,原样存)
ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False)
# 北京时间日期串 'YYYY-MM-DD',按它等值做"按天聚合"(不在 SQL 里做跨时区 date 比较)
report_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return (
f"<AdEcpmRecord user_id={self.user_id} {self.ad_type} "
f"ecpm={self.ecpm_raw} adn={self.adn}>"
)
+101
View File
@@ -0,0 +1,101 @@
"""比价记录表。
每完成一次比价(外卖/电商/领券),客户端在 done 帧后用带 JWT 的通道上报一条,落这里。
是未来「我的比价记录」页的数据源,也沉淀用户级行为画像(哪个用户在哪两家之间比了什么)。
与 savings_record 的区别:savings_record 是「省了多少钱」的视角(只有省到才有意义,当前由
demo seeder 灌),本表是「每一次比价的完整明细」——不省钱、甚至失败的比价也照记一条。
两表独立,互不影响。
「越详细越好」的落地:结构化列给查询/排序/聚合用,raw_payload(JSONB)把客户端上报的
原始 calibration + done.params 原样存一份,未来前端要展示什么都能拿到、不丢信息。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import (
JSON,
Boolean,
DateTime,
ForeignKey,
Integer,
String,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
# PG 上用 JSONB(可建 GIN 索引),SQLite(本地/测试)退化为通用 JSON——
# 否则 SQLite 无 JSONB,Base.metadata.create_all 编译报错(同 savings_record.dishes)。
_JSON = JSON().with_variant(JSONB(), "postgresql")
class ComparisonRecord(Base):
__tablename__ = "comparison_record"
__table_args__ = (
# 同一用户同一次比价(trace_id)只存一条:客户端重试/误点重复上报时幂等覆盖。
UniqueConstraint("user_id", "trace_id", name="uq_comparison_user_trace"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False
)
# 仍记录设备号(同一用户多设备的行为区分 / 与不鉴权期 device_id 数据对账)
device_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 业务类型:food(外卖,当前唯一接通)/ ecom(电商)/ coupon(领券)。预留扩展。
business_type: Mapped[str] = mapped_column(
String(16), nullable=False, default="food", index=True
)
# pricebot 侧 trace_id:关联调试落盘 + 幂等去重键
trace_id: Mapped[str] = mapped_column(String(64), nullable=False)
# ===== 源平台(发起比价的那家)=====
source_platform_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
source_platform_name: Mapped[str | None] = mapped_column(String(32), nullable=True)
source_package: Mapped[str | None] = mapped_column(String(128), nullable=True)
source_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
# ===== 最优结果(全平台最便宜的一家,= comparison_results 里 rank=1)=====
best_platform_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
best_platform_name: Mapped[str | None] = mapped_column(String(32), nullable=True)
best_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 源价 - 最优价(可为 0 / 负:源平台本来就最便宜时没省到)
saved_amount_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 源平台就是最便宜的一家(= 这次没省到钱)
is_source_best: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
# ===== 订单概要 =====
store_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
total_dish_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
skipped_dish_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
# success(拿到有效对比)/ failed(出错或没采到目标价)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="success")
# done 帧 information 文案。成功:"在美团找到同店,到手价 ¥X…";
# 失败:具体原因(如"美团、京东外卖均未找到该商品")。前端在比价失败时当原因展示。
information: Mapped[str | None] = mapped_column(String(256), nullable=True)
# ===== 明细(JSON,越详细越好)=====
# 下单菜品 [{name, qty, specs?}]
items: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
# 逐平台对比 [{platform_id, platform_name, package, price, is_source, rank, coupon_saved}](price/coupon_saved 单位:元,原样存)
comparison_results: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
# 目标平台未找到、跳过的菜名
skipped_dish_names: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
# 客户端上报的原始 payload(calibration + done.params 全量),未来取数兜底
raw_payload: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return (
f"<ComparisonRecord id={self.id} user_id={self.user_id} "
f"trace_id={self.trace_id} status={self.status}>"
)
+36
View File
@@ -0,0 +1,36 @@
"""比价战绩里程碑领取记录表。
「记录比价战绩」每档(第 1~6 次)只能领一次,领取后写一行,(user_id, milestone)
唯一,防止重复领奖。解锁进度由 comparison_record 里 status='success' 的条数决定,
不存进度本身——只在这里记"哪几档已领"。仿 user_task 的一次性领取模型。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class ComparisonMilestoneClaim(Base):
__tablename__ = "comparison_milestone_claim"
__table_args__ = (
UniqueConstraint("user_id", "milestone", name="uq_compare_milestone_user"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False
)
# 档位序号(1-based),见 app.core.rewards.RECORD_MILESTONES
milestone: Mapped[int] = mapped_column(Integer, nullable=False)
coin_awarded: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
claimed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<ComparisonMilestoneClaim user_id={self.user_id} m={self.milestone}>"
+28 -3
View File
@@ -8,7 +8,8 @@ from __future__ import annotations
from datetime import datetime
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, func
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
@@ -16,6 +17,11 @@ from app.db.base import Base
class SavingsRecord(Base):
__tablename__ = "savings_record"
__table_args__ = (
# 真实上报(source='compare')按 (user, client_event_id) 幂等;
# demo 行 client_event_id 为 NULL,不参与唯一性冲突(SQLite/PG 均允许多 NULL)
UniqueConstraint("user_id", "client_event_id", name="uq_savings_user_event"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
@@ -28,11 +34,30 @@ class SavingsRecord(Base):
title: Mapped[str | None] = mapped_column(String(128), nullable=True)
# 店铺名(订单明细卡标题,如「窑鸡王(王府井店)」)
shop_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
# 菜品名列表(JSON),前 2 道直接展示,其余收进「还有 N 道菜」展开。SQLite 存为 TEXT
dishes: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
# 菜品名列表:PG 用 JSONB(可建 GIN 索引),SQLite 用 JSON(TEXT)。前 2 道直接展示,其余收进「还有 N 道菜」。
dishes: Mapped[list[str]] = mapped_column(
JSON().with_variant(JSONB(), "postgresql"), nullable=False, default=list
)
# 来源:demo(演示) / compare(真实比价上报)
source: Mapped[str] = mapped_column(String(16), nullable=False, default="compare")
# ===== 真实比价上报(source='compare')新增字段;demo 行这些均为 NULL =====
# 源平台原价(用户原本要付的钱,分);省额 saved_amount_cents = original order_amount(实付)
original_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 我们当时给出的比价价(分),审计/备用展示
compared_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 支付渠道:wechat / alipay
pay_channel: Mapped[str | None] = mapped_column(String(16), nullable=True)
# 实际下单平台包名
platform_package: Mapped[str | None] = mapped_column(String(128), nullable=True)
# 源平台展示名(如「美团」),用于「原价 ¥36.8(美团)」展示
source_platform_name: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 源平台重进链接(预留:后续「重新进店/重复下单」用,本期只存不展示)
source_deeplink: Mapped[str | None] = mapped_column(String(512), nullable=True)
# 客户端幂等键(UUID);demo 行为 NULL
client_event_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
device_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
+49
View File
@@ -0,0 +1,49 @@
"""广告 eCPM 上报 CRUD(内部收益统计/对账)。
客户端在广告展示后(onAdShow)读到 eCPM,经鉴权接口上报,这里落库鉴权接口已确保
user 存在(JWT),故不做 UnknownUser 校验best-effort 上报:丢一两条不影响业务,
穿山甲后台报表是结算权威兜底
"""
from __future__ import annotations
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.rewards import cn_today
from app.models.ad_ecpm import AdEcpmRecord
def create_ecpm_record(
db: Session,
user_id: int,
*,
ad_type: str,
ecpm_raw: str,
adn: str | None = None,
slot_id: str | None = None,
) -> AdEcpmRecord:
"""落一条 eCPM 上报记录。report_date 用北京时间当天,供按天聚合。"""
rec = AdEcpmRecord(
user_id=user_id,
ad_type=ad_type,
adn=adn,
slot_id=slot_id,
ecpm_raw=ecpm_raw,
report_date=cn_today().isoformat(),
)
db.add(rec)
db.commit()
db.refresh(rec)
return rec
def count_today(db: Session, user_id: int) -> int:
"""该用户今日(北京时间)上报的 eCPM 条数,排查/对账辅助用。"""
return db.execute(
select(func.count())
.select_from(AdEcpmRecord)
.where(
AdEcpmRecord.user_id == user_id,
AdEcpmRecord.report_date == cn_today().isoformat(),
)
).scalar_one()
+27 -40
View File
@@ -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
@@ -86,7 +85,7 @@ def grant_ad_reward(
# 发金币 + 记一笔,同事务
crud_wallet.grant_coins(
db, user_id, coin,
biz_type="ad_reward", ref_id=trans_id, remark="广告奖励",
biz_type="ad_reward", ref_id=trans_id, remark="视频奖励金币",
)
rec = AdRewardRecord(
trans_id=trans_id, user_id=user_id, coin=coin, status="granted",
@@ -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<N(还没完成第一轮)调用方应直接判 None,不进这里
"""
return db.execute(
select(AdRewardRecord.created_at)
.where(
AdRewardRecord.user_id == user_id,
AdRewardRecord.reward_date == reward_date,
AdRewardRecord.status == "granted",
)
.order_by(AdRewardRecord.created_at.desc())
.offset(round_count)
.limit(1)
).scalar_one_or_none()
def _granted_times_today_desc(db: Session, user_id: int, reward_date: str) -> 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,
)
+147
View File
@@ -0,0 +1,147 @@
"""比价记录 CRUD:上报 upsert(按 user_id+trace_id 幂等) + 派生字段 + 明细分页。
派生逻辑:best_* / saved_amount_cents / is_source_best 全部从 comparison_results 算出
(协议保证已按 price 升序rank=1 最便宜),客户端不用自己算也不可信它算
"""
from __future__ import annotations
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.comparison import ComparisonRecord
from app.schemas.compare_record import ComparisonRecordIn
def _yuan_to_cents(yuan: float | None) -> int | None:
"""元(float)→ 分(int)。None 透传。"""
if yuan is None:
return None
return round(yuan * 100)
def _derive(payload: ComparisonRecordIn) -> dict:
"""从上报 payload 派生结构化列(best/saved/is_source_best/status)。"""
results = payload.comparison_results
# 最优 = rank 最小的一条;协议已升序,但不信顺序,显式按 rank/price 兜底取最小价。
best = None
priced = [r for r in results if r.price is not None]
if priced:
best = min(
priced,
key=lambda r: (r.rank if r.rank is not None else 10**9, r.price),
)
source_price_cents = _yuan_to_cents(payload.source_price)
if source_price_cents is None:
# 源价没单独给,从 comparison_results 里的 is_source 行兜底
src_row = next((r for r in results if r.is_source and r.price is not None), None)
if src_row is not None:
source_price_cents = _yuan_to_cents(src_row.price)
best_price_cents = _yuan_to_cents(best.price) if best else None
saved_amount_cents = None
if source_price_cents is not None and best_price_cents is not None:
saved_amount_cents = source_price_cents - best_price_cents
is_source_best = best.is_source if best is not None else None
# status:客户端显式给了就用;否则有"非源且有价"的结果=success,否则 failed
status = payload.status
if status is None:
has_valid_target = any(
(not r.is_source) and r.price is not None for r in results
)
status = "success" if has_valid_target else "failed"
return {
"source_price_cents": source_price_cents,
"best_platform_id": best.platform_id if best else None,
"best_platform_name": best.platform_name if best else None,
"best_price_cents": best_price_cents,
"saved_amount_cents": saved_amount_cents,
"is_source_best": is_source_best,
"status": status,
}
def upsert_record(
db: Session, *, user_id: int, payload: ComparisonRecordIn
) -> ComparisonRecord:
"""按 (user_id, trace_id) 幂等写入:已存在则覆盖(更完整的重试上报胜出),否则新建。"""
derived = _derive(payload)
fields = dict(
device_id=payload.device_id,
business_type=payload.business_type,
store_name=payload.store_name,
source_platform_id=payload.source_platform_id,
source_platform_name=payload.source_platform_name,
source_package=payload.source_package,
information=payload.information,
total_dish_count=payload.total_dish_count,
skipped_dish_count=payload.skipped_dish_count,
items=[it.model_dump(exclude_none=True) for it in payload.items],
comparison_results=[r.model_dump() for r in payload.comparison_results],
skipped_dish_names=list(payload.skipped_dish_names),
raw_payload=payload.model_dump(),
**derived,
)
existing = db.execute(
select(ComparisonRecord).where(
ComparisonRecord.user_id == user_id,
ComparisonRecord.trace_id == payload.trace_id,
)
).scalar_one_or_none()
if existing is not None:
for k, v in fields.items():
setattr(existing, k, v)
db.commit()
db.refresh(existing)
return existing
rec = ComparisonRecord(user_id=user_id, trace_id=payload.trace_id, **fields)
db.add(rec)
db.commit()
db.refresh(rec)
return rec
def list_records(
db: Session,
user_id: int,
*,
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[ComparisonRecord], int | None]:
"""比价记录分页(按 id 倒序,游标式)。"""
stmt = select(ComparisonRecord).where(ComparisonRecord.user_id == user_id)
if cursor is not None:
stmt = stmt.where(ComparisonRecord.id < cursor)
stmt = stmt.order_by(ComparisonRecord.id.desc()).limit(limit)
items = list(db.execute(stmt).scalars().all())
next_cursor = items[-1].id if len(items) == limit else None
return items, next_cursor
def count_success(db: Session, user_id: int) -> int:
"""该用户成功比价(status='success')的条数。比价战绩里程碑的解锁进度源。"""
return db.execute(
select(func.count(ComparisonRecord.id)).where(
ComparisonRecord.user_id == user_id,
ComparisonRecord.status == "success",
)
).scalar_one()
def get_record(db: Session, user_id: int, record_id: int) -> ComparisonRecord | None:
"""取单条(限本人,避免越权读他人记录)。"""
return db.execute(
select(ComparisonRecord).where(
ComparisonRecord.id == record_id,
ComparisonRecord.user_id == user_id,
)
).scalar_one_or_none()
+115
View File
@@ -0,0 +1,115 @@
"""比价战绩里程碑 CRUD:进度查询 + 逐档领奖。
进度 = comparison_record status='success' 的条数(crud_compare.count_success)
N 档在"成功次数 >= N"时解锁;每档领一次, comparison_milestone_claim 去重(标记已领)
当前产品定暂不真发金币(后续整体删除该功能):claim 只写领取记录,不调 grant_coins
不写 coin_transaction,coin_awarded 恒为 0,余额不变仿一次性任务 (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,
)
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
# ⚠️ 比价战绩里程碑暂不真发金币(产品定,后续整体删除该功能):仍记一条领取(去重/标记已领),
# 但不调 grant_coins、不写 coin_transaction,余额不变,返回发放 0 金币 + 当前余额。
db.add(
ComparisonMilestoneClaim(
user_id=user_id, milestone=milestone, coin_awarded=0
)
)
acc = crud_wallet.get_or_create_account(db, user_id, commit=False)
db.commit()
return 0, acc.coin_balance
+85 -9
View File
@@ -17,6 +17,7 @@ from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ, cn_today
from app.models.savings import SavingsRecord
from app.schemas.order import OrderReportRequest
# 演示数据规模:最近 N 天每天 1 单(撑起"连续省钱"和"本周"),再补若干历史单
_DEMO_STREAK_DAYS = 9
@@ -65,6 +66,26 @@ def _all_records(db: Session, user_id: int) -> list[SavingsRecord]:
return list(db.execute(stmt).scalars().all())
def _has_real(db: Session, user_id: int) -> bool:
"""该用户是否已有真实比价上报(source='compare')记录。"""
return db.execute(
select(SavingsRecord.id)
.where(SavingsRecord.user_id == user_id, SavingsRecord.source == "compare")
.limit(1)
).first() is not None
def _effective_records(db: Session, user_id: int) -> list[SavingsRecord]:
"""统计口径:有真实(compare)记录就只用真实的;否则 seed 一批 demo 并用 demo 兜底。"""
if _has_real(db, user_id):
stmt = select(SavingsRecord).where(
SavingsRecord.user_id == user_id, SavingsRecord.source == "compare"
)
return list(db.execute(stmt).scalars().all())
ensure_seeded(db, user_id)
return _all_records(db, user_id)
def ensure_seeded(db: Session, user_id: int) -> None:
"""该用户没有任何省钱记录时,幂等灌一批 demo 数据。"""
exists = db.execute(
@@ -108,8 +129,7 @@ def ensure_seeded(db: Session, user_id: int) -> None:
def get_summary(db: Session, user_id: int) -> SavingsSummary:
ensure_seeded(db, user_id)
records = _all_records(db, user_id)
records = _effective_records(db, user_id)
total = sum(r.saved_amount_cents for r in records)
count = len(records)
avg = total // count if count else 0
@@ -129,8 +149,7 @@ def _streak_days(dates: set) -> int:
def get_battle(db: Session, user_id: int) -> SavingsBattle:
ensure_seeded(db, user_id)
records = _all_records(db, user_id)
records = _effective_records(db, user_id)
today = cn_today()
week_start = today - timedelta(days=today.weekday()) # 本周一
@@ -155,10 +174,20 @@ def _compute_beat_percent(db: Session, user_id: int) -> int:
全部省得比我少 100;只有自己一个用户 0(无可比)
"""
rows = db.execute(
select(SavingsRecord.user_id, func.sum(SavingsRecord.saved_amount_cents))
.group_by(SavingsRecord.user_id)
select(
SavingsRecord.user_id,
SavingsRecord.source,
func.sum(SavingsRecord.saved_amount_cents),
).group_by(SavingsRecord.user_id, SavingsRecord.source)
).all()
totals = {uid: (total or 0) for uid, total in rows}
# 每用户口径与展示一致:有 compare 用 compare 之和,否则退回 demo 之和
by_user: dict[int, dict[str, int]] = {}
for uid, source, total in rows:
by_user.setdefault(uid, {})[source] = total or 0
totals = {
uid: (d["compare"] if "compare" in d else d.get("demo", 0))
for uid, d in by_user.items()
}
others = {uid: t for uid, t in totals.items() if uid != user_id}
if not others:
return 0
@@ -174,9 +203,12 @@ def list_records(
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[SavingsRecord], int | None]:
"""省钱明细分页(按 id 倒序,游标式)。"""
ensure_seeded(db, user_id)
"""省钱明细分页(按 id 倒序,游标式)。有真实(compare)记录只列真实,否则 demo 兜底。"""
stmt = select(SavingsRecord).where(SavingsRecord.user_id == user_id)
if _has_real(db, user_id):
stmt = stmt.where(SavingsRecord.source == "compare")
else:
ensure_seeded(db, user_id)
if cursor is not None:
stmt = stmt.where(SavingsRecord.id < cursor)
stmt = stmt.order_by(SavingsRecord.id.desc()).limit(limit)
@@ -184,3 +216,47 @@ def list_records(
items = list(db.execute(stmt).scalars().all())
next_cursor = items[-1].id if len(items) == limit else None
return items, next_cursor
def create_from_report(
db: Session, user_id: int, req: OrderReportRequest
) -> tuple[SavingsRecord, bool]:
"""真实比价上报 → 写入一条 savings_record(source='compare')。返回 (记录, 是否重复上报)。
省额 = 源平台原价 实付(下限 0);原价缺失则记 0
幂等: (user_id, client_event_id) 已存在则直接返回旧记录,duplicated=True,不新增
"""
existing = db.execute(
select(SavingsRecord).where(
SavingsRecord.user_id == user_id,
SavingsRecord.client_event_id == req.client_event_id,
)
).scalar_one_or_none()
if existing is not None:
return existing, True
paid = req.paid_amount_cents
original = req.original_price_cents
saved = max(0, original - paid) if original is not None else 0
rec = SavingsRecord(
user_id=user_id,
order_amount_cents=paid,
saved_amount_cents=saved,
original_price_cents=original,
compared_price_cents=req.compared_price_cents,
platform=req.platform,
title=req.shop_name, # 明细卡标题用门店名
shop_name=req.shop_name,
dishes=req.dishes or [],
pay_channel=req.pay_channel,
platform_package=req.platform_package,
source_platform_name=req.source_platform_name,
source_deeplink=req.source_deeplink,
client_event_id=req.client_event_id,
device_id=req.device_id,
source="compare",
)
db.add(rec)
db.commit()
db.refresh(rec)
return rec, False
+2 -1
View File
@@ -119,7 +119,8 @@ def do_signin(db: Session, user_id: int) -> tuple[SigninRecord, int]:
)
db.add(record)
acc, _ = crud_wallet.grant_coins(
db, user_id, coin, biz_type="signin", ref_id=today.isoformat()
db, user_id, coin, biz_type="signin", ref_id=today.isoformat(),
remark=f"每日签到 第{cycle_day}",
)
db.commit()
db.refresh(record)
+18
View File
@@ -39,6 +39,24 @@ class AdRewardStatusOut(BaseModel):
)
class EcpmReportIn(BaseModel):
"""客户端上报一次广告展示的 eCPM(内部收益统计/对账)。
user_id 不在 body JWT (Bearer),防伪造ecpm 原样上报字符串(单位待确认)
"""
ad_type: str = Field(..., description="广告类型:reward_video(激励视频) / draw(Draw 信息流) 等")
ecpm: str = Field(..., description="穿山甲 getShowEcpm().getEcpm() 原始字符串,单位待确认,原样上报")
adn: str | None = Field(None, description="实际投放 ADN(getSdkName),如 pangle")
slot_id: str | None = Field(None, description="实际展示代码位(底层 mediation rit)")
class EcpmReportOut(BaseModel):
"""eCPM 上报结果。best-effort,落库即 ok。"""
ok: bool = True
class TestGrantOut(BaseModel):
"""[仅本地联调]模拟发奖结果。带上今日进度,客户端可直接据此刷新展示。"""
+129
View File
@@ -0,0 +1,129 @@
"""比价记录上报 / 读取 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
# 该平台本单用红包省的**纯红包优惠额**(元, 不含配送费/代金券/满减)。None=没用/没抠到。
# 必须显式声明: 落库走 model_dump(), pydantic 默认丢未知字段, 不声明这行会被悄悄吞掉。
# 各平台抠到红包即带值(2026-06 起源平台 Phase1 意图识别也抠, 当前仅淘宝源)。见 pricebot 侧 比价红包额留痕-实现方案.md。
coupon_saved: float | 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="该档应发金币额(产品规则值);当前领取暂不真发, 见 compare-milestone-claim 文档")
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="领奖后金币余额")
+30
View File
@@ -0,0 +1,30 @@
from pydantic import BaseModel, ConfigDict, Field
class OrderReportRequest(BaseModel):
"""客户端归因成功后的上报体。金额一律用「分」(int) 传,避免浮点误差。"""
client_event_id: str = Field(..., max_length=64, description="客户端幂等键(UUID)")
platform: str = Field(..., max_length=32, description="平台展示名,如 美团")
platform_package: str | None = Field(None, max_length=128, description="平台包名")
pay_channel: str = Field(..., max_length=16, description="支付渠道 wechat/alipay")
compared_price_cents: int = Field(..., ge=0, description="我们给出的比价价(分)")
paid_amount_cents: int = Field(..., ge=0, description="实际支付金额(分)")
device_id: str | None = Field(None, max_length=128)
# ===== 比价时携带的记账信息(客户端从意图识别阶段缓存而来;旧版客户端可能不传,故全部可空)=====
shop_name: str | None = Field(None, max_length=128, description="门店名,如 肯德基宅急送(天北路店)")
dishes: list[str] = Field(default_factory=list, description="菜品名列表")
original_price_cents: int | None = Field(None, ge=0, description="源平台原价(分),省额=原价−实付")
source_platform_name: str | None = Field(None, max_length=32, description="源平台展示名,如 美团")
source_deeplink: str | None = Field(None, max_length=512, description="源平台重进链接(预留,本期只存不展示)")
class OrderReportOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
platform: str
pay_channel: str
compared_price_cents: int
paid_amount_cents: int
duplicated: bool = False
+5 -2
View File
@@ -200,12 +200,15 @@ class SavingsRecordOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
order_amount_cents: int
saved_amount_cents: int
order_amount_cents: int # 实付金额(分)
saved_amount_cents: int # 省下(分)
original_price_cents: int | None = None # 源平台原价(分);demo 行为空
platform: str | None = None
title: str | None = None
shop_name: str | None = None
dishes: list[str] = []
pay_channel: str | None = None # wechat/alipay;demo 行为空
source_platform_name: str | None = None # 源平台名,如 美团
created_at: datetime
+36 -4
View File
@@ -3,7 +3,7 @@
> Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770`
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**
> 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer <access_token>`
> 最后更新:2026-05-27
> 最后更新: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,16 @@
| 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) |
| 37 | `DELETE /api/v1/user` | Bearer | [详情](./user-delete.md) |
| **帮助与反馈**(前缀 `/api/v1/feedback` |||
| 38 | `POST /api/v1/feedback` | Bearer | [详情](./feedback.md) |
| **静态资源**StaticFiles 挂载,见下方 `/media` 静态服务) |||
| - | `GET /media/avatars/<file>` | 无 | 用户头像;返回二进制图片 |
| - | `GET /media/feedback/<file>` | 无 | 反馈截图;返回二进制图片 |
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link``sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
> `coupon/step` 及外卖比价的 `intent/recognize` / `price/step` 都透传到 pricebot-backend,**MVP 阶段均不鉴权**(device_id 透传,待补 JWT——见各接口详情)。
@@ -99,9 +116,9 @@
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 用户主键 |
| `phone` | string | 手机号 |
| `nickname` | string \| null | 昵称(当前无接口可改,恒为 null |
| `avatar_url` | string \| null | 头像(同上) |
| `phone` | string | 手机号(注销账号后变 `deleted_<id>` 占位释放唯一约束) |
| `nickname` | string \| null | 昵称,经 [`PATCH /api/v1/user/profile`](./user-profile.md) 修改 |
| `avatar_url` | string \| null | 头像相对 URL(`/media/avatars/...`),经 [`POST /api/v1/user/avatar`](./user-avatar.md) 上传 |
| `register_channel` | string | 注册渠道:`jverify` / `sms` |
| `status` | string | `active` / `disabled` / `deleted` |
| `created_at` | datetime | 注册时间 |
@@ -135,6 +152,21 @@
---
## /media 静态服务
用户上传文件(头像/反馈截图)落盘到 `settings.MEDIA_ROOT`(默认 `./data/media/`),由 FastAPI 的 `StaticFiles` 挂在 `settings.MEDIA_URL_PREFIX`(默认 `/media`)对外暴露:
- `GET /media/avatars/<file>` — 用户头像(JPEG/PNG/WebP)
- `GET /media/feedback/<file>` — 反馈截图(同上)
**生产建议**:由 nginx 直接 serve `MEDIA_ROOT` 目录,绕过应用进程减少压力。
**URL 格式**:服务端返回**相对路径**(如 `/media/avatars/u1_a4f2b3c8e9d2e0a1.jpg`),客户端按自己的 `BASE_URL` 拼绝对地址——dev 下可能是 `10.0.2.2`/LAN IP/`127.0.0.1`,服务端不知道客户端怎么访问到自己。
**文件名服务端随机生成** `u<user_id>_<16 位 hex>.<ext>`,杜绝路径穿越与覆盖。
---
## 附:鉴权与刷新机制
- **签发**:登录成功后签发 access(HS256,2h) + refresh(30d),payload 含 `sub`(user_id)、`typ`(access/refresh)、`iat``exp`
+36
View File
@@ -0,0 +1,36 @@
# POST /api/v1/ad/ecpm-report — 上报本次广告展示的 eCPM(内部收益统计)
> 所属:Ad 组(前缀 `/api/v1/ad` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
## 入参
请求体:`EcpmReportIn`
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `ad_type` | str | 是 | 广告类型:`reward_video`(激励视频) / `draw`(Draw 信息流) 等 |
| `ecpm` | str | 是 | 穿山甲 `getShowEcpm().getEcpm()` 原始字符串,**单位待确认(分/元)**,原样上报 |
| `adn` | str\|null | 否 | 实际投放 ADN(`getSdkName`),如 `pangle` |
| `slot_id` | str\|null | 否 | 实际展示代码位(底层 mediation rit,非客户端配置位) |
`user_id` 不在 body 里——由 JWT 取(Bearer),防伪造。
## 出参
响应 `200`:`EcpmReportOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `ok` | bool | 落库即 `true` |
## 说明
客户端在广告**展示后**(`onAdShow``getShowEcpm()`)调用,把本次展示的 eCPM 落库做**内部收益统计/对账**。
- **best-effort**:客户端 fire-and-forget,丢一两条不影响业务;穿山甲后台报表是结算权威兜底。
- 落 `ad_ecpm_record` 表,`report_date` 用北京时间当天,供「按用户/按天聚合」对账。
- **与发奖是两条独立流**:发奖走 [ad-pangle-callback](./ad-pangle-callback.md)(穿山甲 S2S,有 `trans_id`、无 ecpm),本接口客户端上报(有 ecpm、无 `trans_id`)。两者无公共键,**不逐条一一对应**,只做按用户/按天聚合口径。
- eCPM 是**每千次展示预估**,单条展示预估收益 ≈ `ecpm / 1000`,且为客户端预估口径,非最终结算。
- ⚠️ eCPM **单位(分/元)截至 2026-05-31 未最终确认**,故 `ecpm_raw` 原样存字符串;确认后再加一列解析好的数值。
- 限流:同 IP 120 次/分钟。
## 相关
- [ad-pangle-callback](./ad-pangle-callback.md) — 穿山甲 S2S 发奖回调
- [ad-reward-status](./ad-reward-status.md) — 今日看广告发奖进度
+34
View File
@@ -0,0 +1,34 @@
# POST /api/v1/compare/milestones/{milestone}/claim — 领取比价战绩里程碑奖励
> 所属:比价记录组(前缀 `/api/v1/compare` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
领取某一档(第 `milestone` 次)。⚠️ **当前不真发金币**(产品定,后续整体删除该功能):仍写
`comparison_milestone_claim`((user_id, milestone) 唯一)标记该档已领、**每档只能领一次**,但不调
`grant_coins`、不写 `coin_transaction`,余额不变,`coin_awarded` 恒为 0。进度口径见
[milestones 进度接口](./compare-milestones.md)。
## 入参
- 路径参数 `milestone`(int):档位序号,1..6(= `RECORD_MILESTONES` 长度)。
## 出参
响应 `200`
| 字段 | 类型 | 说明 |
|---|---|---|
| `milestone` | int | 本次领取的档位序号 |
| `coin_awarded` | int | 本次发放金币(当前恒为 `0`,暂不真发) |
| `coin_balance` | int | 领奖后金币余额(当前不变) |
```json
{ "milestone": 1, "coin_awarded": 0, "coin_balance": 29087 }
```
## 错误
- `401` 未鉴权
- `404` 档位越界(`unknown milestone`,milestone < 1 或 > 档位总数)
- `409` 该档还没解锁(`milestone locked`,成功比价次数 < milestone)
- `409` 该档已领过(`milestone already claimed`)
## 说明
- 当前不发金币,客户端领取后只需把本档状态刷成 claimed 即可;若后续恢复发奖,再按 `coin_awarded` 刷新钱包余额([`GET /api/v1/wallet/account`](./wallet-account.md))。
- 幂等:重复领同一档返回 409(唯一约束 + 领取前查重)。
+53
View File
@@ -0,0 +1,53 @@
# 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`),客户端**不要写死**,以本接口返回为准。
> ⚠️ **当前领取暂不真发金币**(产品定,后续整体删除该功能):`coin` 仍返回产品规则值仅供展示,但 [claim 接口](./compare-milestone-claim.md) 实际 `coin_awarded` 恒为 0、余额不变。前端展示须与之对齐,勿让用户误以为领取可到账。
## 入参
无(用户身份取自 Bearer token)。
## 出参
响应 `200`
| 字段 | 类型 | 说明 |
|---|---|---|
| `success_count` | int | 累计成功比价次数(解锁进度) |
| `claimable_count` | int | 当前可领(state=active)的档数 |
| `milestones` | Milestone[] | 各档状态,按 milestone 升序 |
**Milestone**
| 字段 | 类型 | 说明 |
|---|---|---|
| `milestone` | int | 档位序号(1-based),= 解锁所需的成功比价次数 |
| `coin` | int | 该档**应发**金币额(产品规则值);⚠️ 当前领取不真发,见上方说明 |
| `state` | string | `claimed`(已领) / `active`(已解锁可领) / `locked`(未解锁) |
```json
{
"success_count": 2,
"claimable_count": 1,
"milestones": [
{"milestone": 1, "coin": 120, "state": "claimed"},
{"milestone": 2, "coin": 180, "state": "active"},
{"milestone": 3, "coin": 300, "state": "locked"},
{"milestone": 4, "coin": 500, "state": "locked"},
{"milestone": 5, "coin": 800, "state": "locked"},
{"milestone": 6, "coin": 1200, "state": "locked"}
]
}
```
## 错误
- `401` 未鉴权
## 说明
- 同时可有多档处于 `active`(如累计 3 次却一档没领,则前 3 档都可领),逐档调 claim。
- 进度只增不减:领取不消耗成功次数,只是把对应档从 active→claimed。
+25
View File
@@ -0,0 +1,25 @@
# GET /api/v1/compare/records/{record_id} — 比价记录详情
> 所属:比价记录组(前缀 `/api/v1/compare` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
单条比价记录详情,在列表项基础上额外带 `raw_payload`(客户端上报的原始全量),供未来 UI 展示任意细节。
## 入参(path
| 字段 | 类型 | 说明 |
|---|---|---|
| `record_id` | int | 记录 id |
## 出参
响应 `200`**ComparisonRecordDetailOut** = [ComparisonRecordOut](./compare-records.md#出参) 全部字段 + 下列:
| 字段 | 类型 | 说明 |
|---|---|---|
| `raw_payload` | object \| null | 客户端上报的原始 body 全量(calibration + done.params |
## 错误
- `401` 未鉴权
- `404` 记录不存在,或不属于当前用户(限本人,避免越权读他人记录)
## 说明
`404` 同时覆盖「id 不存在」和「id 属于他人」两种情况——不区分以免泄露他人记录是否存在。
+59
View File
@@ -0,0 +1,59 @@
# POST /api/v1/compare/record — 上报一次比价结果(幂等)
> 所属:比价记录组(前缀 `/api/v1/compare` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
比价 `done` 帧后,客户端用**带 JWT 的通道**上报一条比价结果,落 `comparison_record` 表,作为「我的比价记录」数据源 + 用户级行为画像。
> ⚠️ 与不鉴权的透传端点 [`/api/v1/price/step`](./compare-price-step.md) 不同:那是转发壳,本接口按用户维度落库,**必须鉴权**。
> 本轮只做 server 端;客户端在 done 帧后调本接口的改动另起一轮(见 [待办与技术债.md](../待办与技术债.md) P1)。
## 入参(JSON body
字段刻意对齐 pricebot 协议([02_api_protocol.md](../../../pricebot-backend/docs/main/02_api_protocol.md) 的 `calibration` + `done.params`),客户端把 Phase1 的 calibration 和 done 帧 params **零翻译**映射上来即可。
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `trace_id` | string | ✅ | — | pricebot 侧 trace_id。**幂等键**:同用户同 trace_id 重复上报覆盖、返回同一 id |
| `business_type` | string | ❌ | `food` | `food`(外卖,当前唯一接通) / `ecom`(电商) / `coupon`(领券) |
| `device_id` | string \| null | ❌ | null | 设备号 |
| `store_name` | string \| null | ❌ | null | 店铺名(外卖,来自 calibration.result |
| `source_platform_id` | string \| null | ❌ | null | 源平台代号,如 `taobao_flash` |
| `source_platform_name` | string \| null | ❌ | null | 源平台中文名 |
| `source_package` | string \| null | ❌ | null | 源平台 Android 包名 |
| `source_price` | float \| null | ❌ | null | 源平台到手价(**元** |
| `items` | Item[] | ❌ | [] | 下单菜品,`{name, qty, specs?}` |
| `comparison_results` | Result[] | ❌ | [] | 逐平台对比,见下表(price 单位**元**) |
| `skipped_dish_count` | int \| null | ❌ | null | 目标平台未找到、跳过的菜品数 |
| `skipped_dish_names` | string[] | ❌ | [] | 被跳过的菜名 |
| `total_dish_count` | int \| null | ❌ | null | 原始菜品总数 |
| `information` | string \| null | ❌ | null | done 帧文案(成功摘要 / 失败具体原因),持久化为列并在列表/详情返回,前端失败时当原因展示 |
| `status` | string \| null | ❌ | null | `success` / `failed`。**不传则服务端派生** |
**Item**`{ name: string, qty: int=1, specs: string[]\|null }`
**Resultcomparison_results 元素)**`{ platform_id, platform_name, package, price(元,float\|null), is_source(bool), rank(int\|null), coupon_saved(元,float\|null) }`
- `coupon_saved`:该平台本单**平台主优惠额**(元)——美团红包 / 淘宝平台红包 / 京东优惠券·百亿补贴,**只取那一笔**,不含配送费减免/共减总额。仅外卖目标平台带值,源平台/没用为 `null`,前端记录页据此展示**「已优惠 ¥X」**(null 不展示)。
## 服务端派生(客户端不用算)
`comparison_results` 派生并落结构化列:
- `best_*`:取最便宜的一家(按 `rank` 升序、再按 `price`),写 `best_platform_id/name/best_price_cents`
- `source_price_cents`:优先用 `source_price`,缺失则取 `comparison_results``is_source=true` 行的价
- `saved_amount_cents` = `源价 最优价`(可为 0 / 负:源平台本来就最便宜)
- `is_source_best`:最便宜的一家是否为源平台(= 这次没省到)
- `status`(未显式传时):存在「非源且有价」的结果 → `success`,否则 → `failed`
- 金额一律 `round(元 × 100)` 存**分**
`comparison_results` / `items` / `skipped_dish_names` 原样存 JSON;完整上报 body 另存 `raw_payload`(详情接口可取回)。
## 出参
响应 `200``{ "id": int }`(写入或已存在记录的 id
## 错误
- `401` 未鉴权
- `422``trace_id` 或字段类型不符
## 说明
失败的比价(done 帧只有源/空 comparison_results)也会落一条 `status=failed`——「越详细越好」,是否在 UI 展示由前端过滤。
+47
View File
@@ -0,0 +1,47 @@
# GET /api/v1/compare/records — 比价记录列表(游标分页)
> 所属:比价记录组(前缀 `/api/v1/compare` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
「我的比价记录」列表页数据源。按 `id` 倒序(最新在前)。
## 入参(query
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `limit` | int | ❌ | 20 | 1100 |
| `cursor` | int | ❌ | null | 上一页末条 `id`,首页不传 |
## 出参
响应 `200``{ items: ComparisonRecordOut[], next_cursor: int|null }`(分页见 [索引#游标分页约定](./README.md#游标分页约定)
**ComparisonRecordOut**(列表项,不含 `raw_payload`,减小 payload
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 记录 id(也是游标) |
| `business_type` | string | `food` / `ecom` / `coupon` |
| `trace_id` | string | pricebot trace_id |
| `source_platform_id` | string \| null | 源平台代号 |
| `source_platform_name` | string \| null | 源平台中文名 |
| `source_package` | string \| null | 源平台包名 |
| `source_price_cents` | int \| null | 源平台到手价(分) |
| `best_platform_id` | string \| null | 最优平台代号 |
| `best_platform_name` | string \| null | 最优平台中文名 |
| `best_price_cents` | int \| null | 最优价(分) |
| `saved_amount_cents` | int \| null | 省下(分,可 0/负) |
| `is_source_best` | bool \| null | 源平台是否最便宜(= 没省到) |
| `store_name` | string \| null | 店铺名 |
| `total_dish_count` | int \| null | 菜品总数 |
| `skipped_dish_count` | int \| null | 跳过菜品数 |
| `status` | string | `success` / `failed` |
| `information` | string \| null | done 帧文案。成功:"在美团找到同店,到手价 ¥X…";失败:具体原因(如"美团、京东外卖均未找到该商品"),前端在 `status=failed` 时当原因展示 |
| `items` | object[] | 下单菜品 `{name, qty, specs?}` |
| `comparison_results` | object[] | 逐平台对比(price 单位元,已按 rank 升序) |
| `skipped_dish_names` | string[] | 被跳过的菜名 |
| `created_at` | datetime | 时间 |
## 错误
- `401` 未鉴权
## 说明
只返回当前登录用户自己的记录。需要单条全量(含 `raw_payload`)走 [详情接口](./compare-record-detail.md)。
+40
View File
@@ -0,0 +1,40 @@
# POST /api/v1/feedback — 提交反馈
> 所属:Feedback 组(前缀 `/api/v1/feedback` | 鉴权:Bearer access_token | [← 返回 API 索引](./README.md)
## 入参
**multipart/form-data**:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `content` | string | ✓ | 反馈正文,**1-2000 字**(strip 后) |
| `contact` | string | ✓ | 联系方式(微信/QQ/手机号),**1-128 字**,便于回访 |
| `images` | file[] | ✗ | 截图,**最多 4 张**,每张走头像同款校验(JPEG/PNG/WebP,≤ 5 MB,魔数嗅探) |
## 出参
响应 `200`:
```json
{
"id": 123,
"status": "new",
"created_at": "2026-05-29T12:34:56Z"
}
```
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 反馈单 ID |
| `status` | string | `new`(待处理)/ `handled`(已处理) |
| `created_at` | datetime | 提交时间(UTC) |
> 不返回上传的 image URL——这是给运营后台看的,客户端通常不需要。
## 错误码
- `400` 内容为空 / 内容超 2000 字 / 联系方式为空 / 联系方式超 128 字 / 图片超 4 张 / 单图非法(空/过大/格式不对)
- `401` 未带 token / token 无效或过期 / 用户被禁用
- `422``content``contact` 字段
## 说明
- **反馈绑用户**:`feedback.user_id = current_user.id`,便于回访
- **截图落盘**:存到 `settings.MEDIA_ROOT/feedback/`,URL 写进 `feedback.images`(JSON 列,SQLite 是 TEXT、PG 是 JSON/JSONB——为不影响现有迁移用 JSON 而非 JSONB)
- **截图可读**:`GET /media/feedback/<file>` 静态服务暴露,详见 [API 索引 — /media 静态服务](./README.md#media-静态服务)
- 文件名随机 `u<user_id>_<rand>.<ext>`,杜绝路径穿越
+3 -2
View File
@@ -23,7 +23,8 @@
响应 `200`:`{ items: CouponCard[], has_next: bool, search_id: string|null }``CouponCard` 结构见 [API 索引](./README.md#复用数据结构)。
## 错误码
- `502` 美团接口失败
- `502` 美团接口失败(仅在**已配置 `MT_CPS_APP_KEY/APP_SECRET` 但调用失败**时;未配凭证场景见下)
## 说明
当前 Android 客户端**未调用**此接口(搜索功能尚未实现),仅后端实现完整
- 当前 Android 客户端**未调用**此接口(搜索功能尚未实现),仅后端实现完整
- **未配置 MT_CPS 凭证时降级**:`settings.mt_cps_configured == false` 时直接返 `{ items: [], has_next: false, search_id: null }`,**不报 502**。设计目的是让新开发机首屏不炸——后端业务层先不依赖 CPS。已配凭证但调美团失败时才走 502
+8 -2
View File
@@ -17,9 +17,15 @@
响应 `200`:`{ items: CouponCard[], has_next: bool, page: int }``CouponCard` 结构见 [API 索引](./README.md#复用数据结构)。
## 错误码
见"说明"中的可观测性盲区
(任何失败场景都返 200 + 空 items,见"说明"中的可观测性盲区)
## 说明
后端在 `coupons` 之上封装的"伪推荐"——每页并发拉一次外卖、一次到店(到餐)榜单,按 **2 外卖 + 1 到店** 交叉去重。榜单组合写死 3 页(第1页爆款、第2页今日必推、第3页外卖精选+到店限时),**第 3 页起 `has_next=false`、第 4 页返空**。
⚠️ **可观测性盲区**:`feed` 内部对美团调用异常是静默吞掉返回空列表(不报 502、不打日志)。若 `items` 为空但无错误,优先用 `coupons` 接口逼出真实错误(它会以 502 暴露,如"MT_CPS_APP_KEY not configured")。
**两种空结果路径**:
- **未配置 `MT_CPS_APP_KEY/APP_SECRET`**:开头 `settings.mt_cps_configured` 判定后**直接返空**,完全不调美团
- **已配置但美团调用失败**:`_fetch_topic``except MeituanCpsError` **静默返空**(不报 502、不打日志)
⚠️ **可观测性盲区**:两种路径在响应上**无法区分**。若 `items` 为空想区分:
- 看 `settings.mt_cps_configured`(`/health` 接口有暴露此字段)→ 区分"配置缺失" vs "调用失败"
- 改调 `coupons` 接口——它在"已配凭证但调用失败"时会 502 暴露错误细节(未配凭证它也降级返空,跟 feed 一致)
+4 -4
View File
@@ -23,9 +23,9 @@
| `link_map` | object | 各类型链接 `{ "linkType": "url" }`,如 `{"1": "<H5>", "3": "<deeplink>"}` |
## 错误码
- `502` 美团接口失败
- `502` 美团接口失败(仅在**已配置 `MT_CPS_APP_KEY/APP_SECRET` 但调用失败**时;未配凭证场景见下)
## 说明
客户端实际优先用 `link_map["3"]`deeplink)拉起美团 App,失败降级 `link_map["1"]`H5
⚠️ **安全**:`sid` 允许客户端传值覆盖服务端默认渠道,理论上他人可借本接口刷自己渠道的分佣;建议服务端锁定 `sid`、忽略客户端传值
- 客户端实际优先用 `link_map["3"]`deeplink)拉起美团 App,失败降级 `link_map["1"]`H5
- **未配置 MT_CPS 凭证时降级**:`settings.mt_cps_configured == false` 时直接返 `{ link: "", link_map: {} }`,**不报 502**。客户端拿到空 link 时跳转会失败——客户端应做兜底(toast "暂无可用链接"或不显示「抢」按钮)
- ⚠️ **安全**:`sid` 允许客户端传值覆盖服务端默认渠道,理论上他人可借本接口刷自己渠道的分佣;建议服务端锁定 `sid`、忽略客户端传值
+26
View File
@@ -0,0 +1,26 @@
# POST /api/v1/user/avatar — 上传头像
> 所属:User 组(前缀 `/api/v1/user` | 鉴权:Bearer access_token | [← 返回 API 索引](./README.md)
## 入参
**multipart/form-data**:
| 字段 | 类型 | 说明 |
|---|---|---|
| `file` | file | 头像图片,**仅 JPEG / PNG / WebP**,**≤ 5 MB** |
> 服务端**按文件头魔数嗅探真实类型**(`FF D8 FF` / `89 50 4E 47` / `RIFF…WEBP`),不信任 Content-Type 与文件名;不在三种之内的一律拒。
## 出参
响应 `200`:`UserOut`(`avatar_url` 字段已更新为新地址,形如 `/media/avatars/u<uid>_<rand>.jpg`)。结构见 [API 索引](./README.md#复用数据结构)。
> 返回**相对路径**——客户端按自己的 `BASE_URL` 拼绝对地址(dev 下 `10.0.2.2`/LAN IP 都可能,服务端不知道客户端怎么访问到自己)。读图走 [/media 静态服务](./README.md#media-静态服务) 的 `GET /media/...`
## 错误码
- `400` 空文件 / 超过 5 MB / 非 JPEG/PNG/WebP
- `401` 未带 token / token 无效或过期 / 用户被禁用
- `422``file` 字段
## 说明
- **旧头像清理**:落库成功后才删旧文件,避免"删了新的没存上"的丢图风险;非本服务托管的 URL(如微信头像)不动
- **文件名服务端随机生成** `u<user_id>_<16 位 hex>.<ext>`,杜绝路径穿越与覆盖
- 存储目录:`settings.MEDIA_ROOT/avatars/`,默认 `data/media/avatars/`(生产 nginx 注意把它放共享卷或对象存储,见 [后端技术实现.md](../后端技术实现.md))
+18
View File
@@ -0,0 +1,18 @@
# DELETE /api/v1/user — 注销账号
> 所属:User 组(前缀 `/api/v1/user` | 鉴权:Bearer access_token | [← 返回 API 索引](./README.md)
## 入参
无(身份取自 Header token)
## 出参
响应 `200`:`{ "ok": true }`
## 错误码
- `401` 未带 token / token 无效或过期 / 用户被禁用(已注销账号再次调返 401,因 `status=deleted` 不再 active)
## 说明
- **软删除 + 匿名化**:`status="deleted"` + `phone="deleted_<id>"`(释放 phone 唯一约束,允许同号码重新注册成全新账号)+ `nickname`/`avatar_url` 清空。行保留用于审计 / 关联表外键完整性
- **头像文件物理删除**:本服务托管的头像文件(`/media/avatars/...`)同时删盘;微信头像等外部 URL 不动
- **后续登录**:`status=deleted` 的行登录接口校验不过,所以"注销账号"后旧 token 仍能用直到自然过期(2h),但一过期就再也换不出新 token。客户端应在调本接口后立刻清本地 token
- **未来若加 jti 黑名单**:本接口应同时把当前 access/refresh 加入黑名单实现"立即吊销"
+20
View File
@@ -0,0 +1,20 @@
# PATCH /api/v1/user/profile — 修改昵称
> 所属:User 组(前缀 `/api/v1/user` | 鉴权:Bearer access_token | [← 返回 API 索引](./README.md)
## 入参
请求体 JSON:
| 字段 | 类型 | 说明 |
|---|---|---|
| `nickname` | string | 昵称,**1-16 字**(前后空格自动 strip,strip 后为空判 400) |
## 出参
响应 `200`:`UserOut`(返回更新后的完整用户对象,`nickname` 字段已更新)。结构见 [API 索引](./README.md#复用数据结构)。
## 错误码
- `401` 未带 token / token 无效或过期 / 用户被禁用
- `422` 字段校验失败(`nickname` 缺失 / 长度超 16 字 / 全空白)
## 说明
- **服务端持久化**:写入 `user.nickname` 列。这是昵称的唯一数据源——客户端之前的本地缓存退登即丢,改完后下次登录走 [`/auth/me`](./auth-me.md) 与登录响应回传仍生效
- **strip 行为**:服务端会先去除前后空白再校验长度,所以 `" bob "` 等价于 `"bob"`
+37
View File
@@ -0,0 +1,37 @@
# 傻瓜比价 App 后端 — 数据库表文档(索引)
> 数据库:SQLite 起步(`data/app.db`),生产可切 PostgreSQL(改 `DATABASE_URL`)。
> ORM:SQLAlchemy 2.0(`app/models/`),迁移:Alembic(`alembic/versions/`,`render_as_batch` 兼容 SQLite)。
> 金额字段一律存**整数**:金币=个数,现金=**分**(`*_cents`)。时间列 `DateTime(timezone=True)`
> 最后更新:2026-05-31
---
## 表总览
| 表 | 用途 | 模型 | 关联模块 | 文档 |
|---|---|---|---|---|
| `user` | 用户(登录主体) | `models/user.py` | 登录/鉴权 | [详情](./user.md) |
| `coin_account` | 金币+现金余额快照(一用户一行) | `models/wallet.py` | 钱包 | [详情](./coin_account.md) |
| `coin_transaction` | 金币流水账本 | `models/wallet.py` | 钱包 | [详情](./coin_transaction.md) |
| `cash_transaction` | 现金流水账本(分) | `models/wallet.py` | 钱包/提现 | [详情](./cash_transaction.md) |
| `withdraw_order` | 提现单(现金→微信零钱) | `models/wallet.py` | 提现 | [详情](./withdraw_order.md) |
| `signin_record` | 签到记录(7 天循环) | `models/signin.py` | 签到 | [详情](./signin_record.md) |
| `user_task` | 一次性任务领取去重 | `models/task.py` | 任务 | [详情](./user_task.md) |
| `savings_record` | 省钱记录(profile 省钱战绩源) | `models/savings.py` | 省钱 | [详情](./savings_record.md) |
| `ad_reward_record` | 看激励视频发奖记录(S2S 回调) | `models/ad_reward.py` | 看广告发奖 | [详情](./ad_reward_record.md) |
| `ad_ecpm_record` | 广告展示 eCPM 上报(收益对账) | `models/ad_ecpm.py` | 看广告 | [详情](./ad_ecpm_record.md) |
| `feedback` | 用户帮助与反馈 | `models/feedback.py` | 反馈 | [详情](./feedback.md) |
| `comparison_record` | 比价记录(每次比价完整明细) | `models/comparison.py` | 比价记录 | [详情](./comparison_record.md) |
| `comparison_milestone_claim` | 比价战绩里程碑领取记录 | `models/comparison_milestone.py` | 比价记录/福利 | [详情](./comparison_milestone_claim.md) |
---
## 通用约定
- **主键**:`id` Integer autoincrement(`coin_account` 例外:`user_id` 既是主键也是外键,一用户一行)。
- **外键**:所有用户维度表 `user_id``user.id`,且建 index。
- **金额**:整数;金币计数,现金/价格存「分」(`*_cents`)。
- **时间**:`created_at` 等用 `DateTime(timezone=True)` + `server_default=func.now()`;业务"今天"按**北京时间**(见 `core/rewards.cn_today`),跨天计数用「日期串」列(如 `reward_date`/`report_date`)等值查,不在 SQL 里做跨时区 date 比较(SQLite 不可靠)。
- **JSON 列**:用 `JSON().with_variant(JSONB(), "postgresql")` —— PG 上 JSONB(可建 GIN 索引),SQLite 退化为通用 JSON(否则 `create_all` 编译报错)。
- **迁移**:改表必写 alembic 迁移并保持单 head;改表/建表同时更新本目录对应文档(一表一文件)。
+26
View File
@@ -0,0 +1,26 @@
# ad_ecpm_record — 广告展示 eCPM 上报(收益对账)
> 模型 `app/models/ad_ecpm.py` | 关联接口 [ad-ecpm-report](../api/ad-ecpm-report.md) | [← 表索引](./README.md)
每条 = 客户端一次广告展示(`onAdShow`)后读到的 eCPM。与发奖记录 `ad_reward_record` 是**两条独立数据流**(发奖走 S2S 有 trans_id 无 ecpm;eCPM 走客户端有 ecpm 无 trans_id),无公共键,只用于**按用户/按天聚合**收益对账,不做逐条精确关联。穿山甲后台报表才是结算权威。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `ad_type` | String(32) | NOT NULL | 广告类型:`reward_video`(激励视频)/ `draw`(Draw 信息流)等 |
| `adn` | String(32) | nullable | 实际投放 ADN(`getSdkName`,如 pangle/gdt) |
| `slot_id` | String(64) | nullable | 实际展示代码位(底层 mediation rit) |
| `ecpm_raw` | String(32) | NOT NULL | 客户端上报的 eCPM 原始串(单位待确认,原样存) |
| `report_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它做按天聚合 |
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
## 索引与约束
- PK: `id`;index: `user_id``report_date``created_at`
## 关系
- `user_id``user.id`(多对一)
## 说明
- ⚠️ `ecpm_raw` 单位(分/元)截至 2026-05-31 未最终确认;确认后再加一列解析好的数值,在此之前对账按"待定单位"处理。
+28
View File
@@ -0,0 +1,28 @@
# ad_reward_record — 看激励视频发奖记录(S2S 回调)
> 模型 `app/models/ad_reward.py` | 关联接口 [ad-pangle-callback](../api/ad-pangle-callback.md) / [ad-reward-status](../api/ad-reward-status.md) | [← 表索引](./README.md)
每条 = 穿山甲一次发奖回调。`trans_id` 唯一做幂等键(穿山甲会重试,同号只发一次)。`reward_date`(北京时间日期串)给"每日上限"计数用,按日期串等值查。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `trans_id` | String(64) | UNIQUE, index, NOT NULL | 穿山甲交易号(幂等键) |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户(回调 media_extra 带回) |
| `coin` | Integer | NOT NULL, default 0 | 实发金币(capped 时为 0) |
| `status` | String(16) | NOT NULL, default `granted` | `granted`(已发)/ `capped`(当日超限未发) |
| `reward_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它统计当日次数 |
| `reward_name` | String(64) | nullable | 穿山甲上报奖励名(参考,不作发奖依据) |
| `raw` | String(1024) | nullable | 回调原始参数(审计排查) |
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
## 索引与约束
- PK: `id`;UNIQUE + index: `trans_id`;index: `user_id``reward_date``created_at`
## 关系
- `user_id``user.id`(多对一)
## 说明
- 三道闸:验签不过 403 → `trans_id` 唯一幂等(并发 catch IntegrityError)→ 当日次数 ≥ `DAILY_AD_REWARD_LIMIT``capped` 不发币。
- 发币复用 `grant_coins(biz_type='ad_reward', ref_id=trans_id)`
+26
View File
@@ -0,0 +1,26 @@
# cash_transaction — 现金流水账本(分)
> 模型 `app/models/wallet.py` | 关联接口 [wallet-cash-transactions](../api/wallet-cash-transactions.md) | [← 表索引](./README.md)
现金每次变动一笔流水(单位:分)。金币兑现金、提现、提现退款都记这里。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `amount_cents` | Integer | NOT NULL | 正=入账(兑入),负=出账(提现) |
| `balance_after_cents` | Integer | NOT NULL | 本笔变动后现金余额(分) |
| `biz_type` | String(32) | NOT NULL | `exchange_in`(兑入)/ `withdraw`(提现出账)/ `withdraw_refund`(提现退回) |
| `ref_id` | String(64) | nullable | 关联业务 id(如提现 `out_bill_no`) |
| `remark` | String(128) | nullable | 用户可见备注(如"提现未成功,金额已退回") |
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
## 索引与约束
- PK: `id`;index: `user_id``created_at`
## 关系
- `user_id``user.id`(多对一)
## 说明
- 提现失败/取消退款写 `withdraw_refund`(+X);退款流水 `remark` 是用户可见文案,技术原因记在 `withdraw_order.fail_reason`
+24
View File
@@ -0,0 +1,24 @@
# coin_account — 金币 + 现金余额快照
> 模型 `app/models/wallet.py` | 关联接口 [wallet-account](../api/wallet-account.md) | [← 表索引](./README.md)
一个用户一行的余额快照,供读取展示用;每次余额变动都另写流水(`coin_transaction` / `cash_transaction`)并记 `balance_after`,出问题逐笔回溯。`user_id` 既是主键也是外键。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `user_id` | Integer | PK, FK→user.id | 用户(一用户一行) |
| `coin_balance` | Integer | NOT NULL, default 0 | 当前金币余额 |
| `cash_balance_cents` | Integer | NOT NULL, default 0 | 当前现金余额(分) |
| `total_coin_earned` | Integer | NOT NULL, default 0 | 累计赚取金币(只增不减) |
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 最后更新时间 |
## 索引与约束
- PK: `user_id`(同时是 FK→user.id)
## 关系
- `user_id``user.id`(一对一)
## 说明
- **唯一发金币入口** `wallet.grant_coins` 更新本表余额快照 + 写 `coin_transaction`,不 commit,由调用方同事务提交。
- 提现扣现金用带条件 `UPDATE ... WHERE cash_balance_cents >= amount` 原子扣减,防并发超额。
+27
View File
@@ -0,0 +1,27 @@
# coin_transaction — 金币流水账本
> 模型 `app/models/wallet.py` | 关联接口 [wallet-coin-transactions](../api/wallet-coin-transactions.md) | [← 表索引](./README.md)
金币每次变动一笔流水,记变动后余额,用于对账与「金币明细」展示。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `amount` | Integer | NOT NULL | 正=入账(赚),负=出账(花/兑换) |
| `balance_after` | Integer | NOT NULL | 本笔变动后金币余额(对账用) |
| `biz_type` | String(32) | NOT NULL | 业务类型:`signin` / `task_<key>` / `exchange_out` / `ad_reward` / `compare_milestone` … |
| `ref_id` | String(64) | nullable | 关联业务 id(签到日期 / 任务 key / trans_id / 里程碑序号等) |
| `remark` | String(128) | nullable | 备注 |
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
## 索引与约束
- PK: `id`;index: `user_id``created_at`
## 关系
- `user_id``user.id`(多对一)
## 说明
- 明细接口按 `id` 倒序游标分页。
- 各 `biz_type` 取值由各业务写入(签到/任务/兑换/看广告/比价里程碑),无独立枚举约束,靠写入方约定。
@@ -0,0 +1,26 @@
# comparison_milestone_claim — 比价战绩里程碑领取记录
> 模型 `app/models/comparison_milestone.py` | 关联接口 [compare-milestones](../api/compare-milestones.md) / [compare-milestone-claim](../api/compare-milestone-claim.md) | [← 表索引](./README.md)
「记录比价战绩」每档(第 1~6 次)只能领一次,领取后写一行;解锁进度由 `comparison_record``status='success'` 的条数决定,本表只记"哪几档已领"。仿 `user_task` 的一次性领取模型。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `milestone` | Integer | NOT NULL | 档位序号(1-based),见 `rewards.RECORD_MILESTONES` |
| `coin_awarded` | Integer | NOT NULL, default 0 | 该档应发金币额;⚠️ 当前产品定暂不真发,实际恒为 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'` 计数),本表不存进度本身。
## 说明
- ⚠️ 当前 claim 仅写本表标记已领(去重),**不调 `grant_coins`、不写 `coin_transaction`,`coin_awarded` 恒为 0、余额不变**(产品定暂不真发金币,后续整体删除该功能)。
- 领取校验:档位越界 404、未解锁(成功数 < milestone)409、已领 409。
+47
View File
@@ -0,0 +1,47 @@
# comparison_record — 比价记录(每次比价完整明细)
> 模型 `app/models/comparison.py` | 关联接口 [compare-record-report](../api/compare-record-report.md) / [compare-records](../api/compare-records.md) / [compare-record-detail](../api/compare-record-detail.md) | [← 表索引](./README.md)
每完成一次比价(外卖/电商/领券),客户端 done 帧后用带 JWT 通道上报一条。「我的比价记录」页数据源,也是比价战绩里程碑解锁进度的计数源(`status='success'` 条数)。
> 与 `savings_record` 的区别:本表是「每一次**比价行为**的完整明细」(不省钱、甚至失败也记);`savings_record` 是「真正**下单成交**省了多少」。两表独立、互不喂数据。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `device_id` | String(64) | nullable | 设备号 |
| `business_type` | String(16) | NOT NULL, default `food`, index | `food`(当前唯一接通)/ `ecom` / `coupon` |
| `trace_id` | String(64) | NOT NULL | pricebot 侧 trace_id(关联调试落盘 + 幂等键) |
| `source_platform_id` | String(32) | nullable | 源平台代号 |
| `source_platform_name` | String(32) | nullable | 源平台中文名 |
| `source_package` | String(128) | nullable | 源平台 Android 包名 |
| `source_price_cents` | Integer | nullable | 源平台到手价(分) |
| `best_platform_id` | String(32) | nullable | 最优平台代号(= rank=1) |
| `best_platform_name` | String(32) | nullable | 最优平台中文名 |
| `best_price_cents` | Integer | nullable | 最优价(分) |
| `saved_amount_cents` | Integer | nullable | 源价 最优价(可 0/负) |
| `is_source_best` | Boolean | nullable | 源平台就是最便宜(= 没省到) |
| `store_name` | String(128) | nullable | 店铺名 |
| `total_dish_count` | Integer | nullable | 菜品总数 |
| `skipped_dish_count` | Integer | nullable | 跳过(没找到)菜品数 |
| `status` | String(16) | NOT NULL, default `success` | `success`(拿到有效对比)/ `failed`(出错/没采到目标价) |
| `information` | String(256) | nullable | done 帧文案;成功=摘要,失败=具体原因(前端失败时当原因展示) |
| `items` | JSON(PG: JSONB) | NOT NULL, default [] | 下单菜品 `[{name, qty, specs?}]` |
| `comparison_results` | JSON(PG: JSONB) | NOT NULL, default [] | 逐平台对比 `[{platform_id,platform_name,package,price(元),is_source,rank,coupon_saved}]`;`coupon_saved`=该平台主优惠额(元,美团红包/淘宝平台红包/京东优惠券·百亿补贴,只取那一笔,不含配送费/共减总额),各平台抠到红包即带值(2026-06 起源平台 Phase1 意图识别也抠,当前仅淘宝源),没用/没抠到为 null,前端展示「已优惠 ¥X」 |
| `skipped_dish_names` | JSON(PG: JSONB) | NOT NULL, default [] | 被跳过的菜名 |
| `raw_payload` | JSON(PG: JSONB) | nullable | 客户端原始上报(calibration + done.params 全量) |
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
## 索引与约束
- PK: `id`;index: `user_id``business_type``created_at`
- UNIQUE(`user_id`, `trace_id`) = `uq_comparison_user_trace`(同次比价重试/重复上报幂等覆盖)
## 关系
- `user_id``user.id`(多对一)
- 被 `comparison_milestone_claim` 间接依赖:里程碑解锁进度 = 本表 `status='success'` 条数。
## 说明
- `best_*` / `saved_amount_cents` / `is_source_best` / `status``repositories/comparison.py:_derive``comparison_results` 派生,客户端不用自己算。
- 4 个 JSON 列用 `JSON().with_variant(JSONB(),"postgresql")`(SQLite 退化 JSON)。金额结构化列存「分」,`comparison_results.price` 原样存「元」。
+25
View File
@@ -0,0 +1,25 @@
# feedback — 用户帮助与反馈
> 模型 `app/models/feedback.py` | 关联接口 [feedback](../api/feedback.md) | [← 表索引](./README.md)
每条 = 用户一次反馈提交。`content``contact` 必填,`images` 为可选截图 URL 列表。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 提交用户 |
| `content` | Text | NOT NULL | 反馈正文 |
| `contact` | String(128) | NOT NULL | 联系方式(微信/QQ/手机,便于回访) |
| `images` | JSON | nullable | 截图 URL 列表(相对路径 `/media/feedback/...`);无图为 NULL |
| `status` | String(16) | NOT NULL, default `new` | `new`(待处理)/ `handled`(已处理) |
| `created_at` | DateTime(tz) | server_default now(), index | 提交时间 |
## 索引与约束
- PK: `id`;index: `user_id``created_at`
## 关系
- `user_id``user.id`(多对一)
## 说明
- `images` 用通用 `JSON`(本表未用 JSONB variant);截图先经 `/media/feedback/` 上传拿到相对路径再随反馈提交。
+31
View File
@@ -0,0 +1,31 @@
# savings_record — 省钱记录(profile 省钱战绩源)
> 模型 `app/models/savings.py` | 关联接口 [savings-summary](../api/savings-summary.md) / [savings-battle](../api/savings-battle.md) / [savings-records](../api/savings-records.md) | [← 表索引](./README.md)
profile 页「累计帮你省了 / 省钱战绩 / 省钱明细」的唯一数据源:**真正下单成交后**省了多少记一行。
> ⚠️ **当前为 demo 假数据**:`crud/savings.py:ensure_seeded` 按 user_id 幂等灌 ~23 条(`source='demo'`),聚合(SUM/分组/连续天数)是生产级真实计算。真数据要靠**"用户真下单"信号**(app 目前无:AI 比价止于结算页/结果展示,付款用户手动)——**不是**把比价记录 `comparison_record` 喂过来(那是"比价行为",这是"成交省钱",两个维度)。详见 [[project_shaguabijia_app_server]]。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `order_amount_cents` | Integer | NOT NULL | 订单到手价(分) |
| `saved_amount_cents` | Integer | NOT NULL | 本单省下(分,可为 0) |
| `platform` | String(32) | nullable | 下单平台(美团外卖/淘宝闪购/京东外卖) |
| `title` | String(128) | nullable | 标题 |
| `shop_name` | String(128) | nullable | 店铺名 |
| `dishes` | JSON(PG: JSONB) | NOT NULL, default [] | 菜名列表(前 2 道展示,其余"还有 N 道") |
| `source` | String(16) | NOT NULL, default `compare` | 来源:`demo`(演示)/ `compare`(真实下单上报,待启用) |
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
## 索引与约束
- PK: `id`;index: `user_id``created_at`
## 关系
- `user_id``user.id`(多对一)
## 说明
- `dishes``JSON().with_variant(JSONB(),"postgresql")`(SQLite 退化 JSON)。
- `beat_percent`(超过百分之多少用户)按各用户累计省下金额做真实分位;为有可比人群造了 5 个种子用户(`register_channel='seed'`)。
+26
View File
@@ -0,0 +1,26 @@
# signin_record — 签到记录(7 天循环)
> 模型 `app/models/signin.py` | 关联接口 [signin-status](../api/signin-status.md) / [signin-do](../api/signin-do.md) | [← 表索引](./README.md)
每次签到一行,`(user_id, signin_date)` 唯一,天然防一天签两次。`cycle_day`(1..7)决定发多少金币(`rewards.SIGNIN_REWARDS`),断签重置回 1;`streak` 是连续签到天数(不封顶)用于展示。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `signin_date` | Date | NOT NULL | 签到日期(北京时间 date) |
| `cycle_day` | Integer | NOT NULL | 7 天循环里今天第几档(1..7),决定发币 |
| `streak` | Integer | NOT NULL | 连续签到天数(不封顶) |
| `coin_awarded` | Integer | NOT NULL | 本次发放金币 |
| `created_at` | DateTime(tz) | server_default now() | 时间 |
## 索引与约束
- PK: `id`;index: `user_id`
- UNIQUE(`user_id`, `signin_date`) = `uq_signin_user_date`(防一天签两次)
## 关系
- `user_id``user.id`(多对一)
## 说明
- 签到"今天"按北京时间 `cn_today()`(`CN_TZ=UTC+8`);发币与写本表记录同事务(`grant_coins(biz_type='signin', ref_id=日期)`)。
+32
View File
@@ -0,0 +1,32 @@
# user — 用户(登录主体)
> 模型 `app/models/user.py` | 关联接口 [auth-me](../api/auth-me.md) 等 auth 组 | [← 表索引](./README.md)
登录主体。极光一键登录与短信登录都映射到同一行,以 `phone` 唯一索引;注册即登录(phone 不存在则 insert,存在则更新 `last_login_at`)。后续加微信/Apple 登录新增 oauth_account 表,本表不动。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | 用户主键 |
| `phone` | String(20) | UNIQUE, index, NOT NULL | 手机号(登录主键;注销后置 `deleted_<id>` 释放唯一约束) |
| `register_channel` | String(20) | NOT NULL, default `jverify` | 注册渠道:`jverify` / `sms` |
| `nickname` | String(64) | nullable | 通用昵称(用户改资料设) |
| `avatar_url` | String(512) | nullable | 通用头像相对 URL(`/media/avatars/...`) |
| `wechat_openid` | String(64) | UNIQUE, index, nullable | 微信 openid(绑定后存,提现转账用);一微信一账号 |
| `wechat_nickname` | String(64) | nullable | 微信昵称(绑定时拉,展示在提现绑定卡) |
| `wechat_avatar_url` | String(512) | nullable | 微信头像 URL |
| `status` | String(20) | NOT NULL, default `active` | `active` / `disabled` / `deleted` |
| `created_at` | DateTime(tz) | server_default now() | 注册时间 |
| `last_login_at` | DateTime(tz) | default utcnow(应用层) | 最近登录时间 |
## 索引与约束
- PK: `id`
- UNIQUE + index: `phone`
- UNIQUE + index: `wechat_openid`(允许多个 NULL = 多个未绑定用户)
## 关系
- 被引用方:`coin_account` / `coin_transaction` / `cash_transaction` / `withdraw_order` / `signin_record` / `user_task` / `savings_record` / `ad_reward_record` / `ad_ecpm_record` / `feedback` / `comparison_record` / `comparison_milestone_claim``user_id` 均 → `user.id`
## 说明
- `nickname/avatar_url`(通用)与 `wechat_nickname/wechat_avatar_url`(微信)**分开存,不互相覆盖**。
- 注销账号:phone 改占位串、status=deleted,不物理删行(保留外键完整性)。
+26
View File
@@ -0,0 +1,26 @@
# user_task — 一次性任务领取去重
> 模型 `app/models/task.py` | 关联接口 [tasks-list](../api/tasks-list.md) / [tasks-claim](../api/tasks-claim.md) | [← 表索引](./README.md)
像"打开消息提醒"这类只能领一次的任务,完成后写一行,`(user_id, task_key)` 唯一防重复领奖。可循环领取的任务(签到)不走这张表,有专表 `signin_record`
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `task_key` | String(48) | NOT NULL | 任务标识,见 `rewards.TASK_REWARDS`(如 `enable_notification`) |
| `status` | String(16) | NOT NULL, default `completed` | 任务状态 |
| `coin_awarded` | Integer | NOT NULL, default 0 | 该任务发放金币 |
| `completed_at` | DateTime(tz) | server_default now() | 完成/领取时间 |
## 索引与约束
- PK: `id`;index: `user_id`
- UNIQUE(`user_id`, `task_key`) = `uq_task_user_key`(防重复领)
## 关系
- `user_id``user.id`(多对一)
## 说明
- 领取:写本表 + `grant_coins(biz_type='task_<key>', ref_id=task_key)` 同事务;重复领抛 409,未知 key 抛 404。
- 比价战绩里程碑虽是"领一次"模型但**不复用本表**,另有 `comparison_milestone_claim`(因 task_key 是固定字典,里程碑是按次数解锁的序号)。
+30
View File
@@ -0,0 +1,30 @@
# withdraw_order — 提现单(现金 → 微信零钱)
> 模型 `app/models/wallet.py` | 关联接口 [wallet-withdraw](../api/wallet-withdraw.md) / [wallet-withdraw-status](../api/wallet-withdraw-status.md) | [← 表索引](./README.md)
提现状态机:`pending → success / failed`。扣现金 + 写 `cash_transaction(withdraw)` + 建本单在同一事务;失败/取消时退回现金并写 `cash_transaction(withdraw_refund)``wechat_state` 存微信侧原始状态,`status` 是归一化后的三态。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `out_bill_no` | String(64) | UNIQUE, index, NOT NULL | 商户单号(幂等键 + 微信查单);客户端可传,不传则服务端生成 |
| `amount_cents` | Integer | NOT NULL | 提现金额(分) |
| `status` | String(16) | NOT NULL, default `pending` | 归一化状态:`pending` / `success` / `failed` |
| `wechat_state` | String(32) | nullable | 微信原始状态:`WAIT_USER_CONFIRM` / `SUCCESS` / `FAIL` / `CANCELLED` … |
| `transfer_bill_no` | String(64) | nullable | 微信转账单号 |
| `package_info` | String(512) | nullable | 待用户确认时返回 App 拉起确认页用 |
| `fail_reason` | String(256) | nullable | 失败技术原因(不外露,用户只看流水 remark) |
| `created_at` | DateTime(tz) | server_default now(), index | 发起时间 |
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 更新时间 |
## 索引与约束
- PK: `id`;UNIQUE + index: `out_bill_no`;index: `user_id``created_at`
## 关系
- `user_id``user.id`(多对一)
## 说明
- 资金安全:原子扣款 + `out_bill_no` 幂等 + 模糊失败先查单再决定(绝不盲目退款)+ 孤儿单对账 `reconcile_pending_withdraws`。详见 [[project_shaguabijia_app_server]] 提现段。
- `WITHDRAW_MIN_CENTS=10`(0.1 元,微信地板)。
+14 -1
View File
@@ -33,5 +33,18 @@ METHOD\n + Content-MD5\n + Headers(按 key 升序 "k:v\n") + Url
| `MT_CPS_DEFAULT_SID` | 默认推广位 sid(渠道) |
| `MT_CPS_TIMEOUT_SEC` | 请求超时 |
## 未配凭证时降级(2026-05-28 引入)
api 层 3 个端点都在入口处 `if not settings.mt_cps_configured:` 早返空(`coupons` 返空数组、`feed` 返空 items、`referral-link``link=""` + `link_map={}`),**不报 502**。
**为什么这么设计**:新开发机没填 `MT_CPS_APP_KEY` 时,首页一进就拉 `/feed` → 502 → 整屏错误,首屏体验差。降级后端业务可以先跑起来,等真要测美团 CPS 再补 key。
**坏副作用**:`feed` 本来就有"已配凭证但美团调用失败时 `_fetch_topic` 静默吞错"的可观测性盲区,加上"未配凭证时也返空",**两种空结果路径在响应上无法区分**。排障路径:
- `/health` 接口暴露 `mt_cps_configured: bool`——先看配置
- 改调 `/coupons` 让"已配凭证但调用失败"以 502 + 错误文案暴露细节
## feed 调用静默吞错
`api/v1/meituan.py``_fetch_topic``MeituanCpsError` `except 返回 []` 且**不打日志**。导致美团接口失败时 `feed` 返 200 + 空 items 无声失败。建议补一条 `logger.error` 至少留痕,真实排障还得改调 `coupons`(它在配置就绪时以 502 暴露)。
## 备注
美团接口当前**无鉴权**且换链的 `sid` 允许客户端传值覆盖默认渠道——见对应 api 文档「备注」
- 美团接口当前**无鉴权**,且换链的 `sid` 允许客户端传值覆盖默认渠道——见对应 api 文档「备注」
- 未配凭证降级、feed 静默吞错都在 [待办与技术债.md](../待办与技术债.md) 已记账
-1
View File
@@ -12,7 +12,6 @@
| `query_transfer(out_bill_no)` | 按商户单号查转账单状态 |
| `cancel_transfer(out_bill_no)` | 撤销转账单(仅 `WAIT_USER_CONFIRM`/`ACCEPTED` 可撤)。用户没在确认页确认就离开时调 |
| `encrypt_sensitive(plain)` | 用微信支付平台公钥 OAEP(SHA1) 加密敏感信息(实名) |
| `code_to_openid(code)` | code 换 openid`sns/oauth2`),失败抛 `ValueError` |
| `code_to_userinfo(code)` | code 换 openid + 拉昵称头像,返回 `{openid, nickname, avatar_url, raw}` |
## 签名机制
+284
View File
@@ -0,0 +1,284 @@
# SQLite → PostgreSQL 迁移指南
> 上线前把数据库从 SQLite 切到 PostgreSQL。本服务自始就为这一步预留了路径(`db/session.py` 的 dialect 特判 / `alembic/env.py` 的 batch mode 开关 / SQLAlchemy 2.0 + Alembic),代码改动很小。
>
> **相关文档**:
> - 这份文档讲 **切引擎的完整步骤**(本机装 PG / 建库 / `scripts/init_postgres.py` 用法 / 生产切换)
> - [数据库迁移.md](./数据库迁移.md) 讲 **alembic 操作**(如何建表/升级/新增迁移/多 head 排查/迁移链 12 条)
> - [后端技术实现.md §10](./后端技术实现.md) 列待办与已知问题(含 `init_postgres.py` 已知小 bug)
>
> **背景假设**:当前生产**无真实用户数据**(MVP 阶段),所以整个迁移本质是"切引擎",不涉及数据搬迁。如果将来已有真实数据,本文档不适用,需补充 `pgloader` 演练 + 钱表金额逐行核对 + 停服窗口 + 回滚预案。
>
> 最后更新:2026-05-31
---
## 0. 为什么切
SQLite 三个硬伤决定它不能上线:
- **并发写锁库**:整个文件一把锁,多请求并发写会排队,极易超时。当前 uvicorn `--workers 1` 勉强能跑,扩 worker 立刻撞墙。
- **类型宽松**:整数列能塞字符串、`CHECK` 约束当装饰品。钱表(`wallet` / `withdraw_order` / `cash_transaction`)用 SQLite 等于裸奔。
- **无 JSONB / 无并发索引 / 无分区**:PG 的核心能力一个都享受不到。`savings.dishes` 现在用 SQLite 的 `JSON`(实际存 TEXT),无索引、无操作符,纯展示用。
PG 默认上 16 版(工具链最齐),驱动用 **psycopg3**(SQLAlchemy 2.0 时代默认,不要再装 psycopg2)。
---
## 1. 本地起 PG + 跑通空库(半天)
### 1.1 装 PG
macOS:
```bash
brew install postgresql@16
brew services start postgresql@16
```
Linux(Ubuntu/Debian):
```bash
sudo apt install postgresql-16
sudo systemctl enable --now postgresql
```
### 1.2 建库 + 用户 + 授权
```bash
psql postgres <<'EOF'
CREATE USER shaguabijia_app WITH PASSWORD 'change-me-strong-random';
CREATE DATABASE shaguabijia OWNER shaguabijia_app ENCODING 'UTF8';
GRANT ALL PRIVILEGES ON DATABASE shaguabijia TO shaguabijia_app;
EOF
```
密码用 `python -c "import secrets; print(secrets.token_urlsafe(32))"` 生成,**不要复用 JWT_SECRET_KEY**。
### 1.3 装驱动
```bash
pip install "psycopg[binary]>=3.1"
```
`psycopg[binary]` 是预编译版,免装 libpq 头文件。生产同款,无需源码编译版。
### 1.4 改 `.env`
```ini
DATABASE_URL=postgresql+psycopg://shaguabijia_app:<password>@localhost:5432/shaguabijia
```
> ⚠️ URL scheme 必须是 `postgresql+psycopg://`(显式声明 psycopg3),不能写成 `postgresql://`——SQLAlchemy 默认会去找 psycopg2,装的不一致就报 ModuleNotFoundError。
### 1.5 建表
```bash
alembic upgrade head
```
### 1.6 验证
```bash
psql -U shaguabijia_app -d shaguabijia -c "\dt"
```
应该列出全部 9 张表 + `alembic_version`:
```
user, coin_account, coin_transaction, cash_transaction,
withdraw_order, signin_record, task_claim_record,
savings_record, ad_reward_record, alembic_version
```
再手动 INSERT 一行 user,确认 `created_at` 默认值正常落:
```sql
INSERT INTO "user" (phone, register_channel, status) VALUES ('13800138000', 'sms', 'active');
SELECT id, phone, created_at, last_login_at FROM "user";
```
`created_at` 应该是当前时间戳带时区,不是 NULL。`user` 表名是 PG 关键字,**必须用双引号**包裹。
---
## 2. 测试套件全绿(1 小时)
### 2.1 测试连 PG
测试默认连 SQLite。临时让 `tests/conftest.py``DATABASE_URL` 也指到本地 PG(可以建个 `shaguabijia_test` 库专门跑测试,免得污染 dev 库)。
```bash
createdb -O shaguabijia_app shaguabijia_test
DATABASE_URL=postgresql+psycopg://shaguabijia_app:xxx@localhost:5432/shaguabijia_test pytest
```
### 2.2 处理失败用例
**所有红色测试都不能放过**——SQLite 类型/约束都很宽松,PG 严格起来暴露的就是真 bug。常见问题:
- **字符串和整数比较**:SQLite 允许 `WHERE phone = 13800138000`(自动转字符串),PG 直接报错。改成 `:phone` 参数 + 类型严格
- **timezone 处理**:SQLite 存 naive datetime,PG 的 `TIMESTAMPTZ` 必须带时区。检查所有 `datetime.utcnow()`,改成 `datetime.now(timezone.utc)`
- **隐式 commit**:SQLite 某些情况下隐式 commit,PG 严格事务边界。如果某用例报 `current transaction is aborted`,说明业务代码缺 commit/rollback
### 2.3 验证
`pytest` 全绿,**且本地起 `./run.sh` 后用 Apifox 调通登录 / 美团 feed / 领券透传三个真接口**。
---
## 3. 代码扫雷(1 天)
### 3.1 必改清单
| 文件 / 位置 | 改动 | 原因 |
|---|---|---|
| `app/models/savings.py` | `from sqlalchemy import JSON``from sqlalchemy.dialects.postgresql import JSONB`;`mapped_column(JSON, ...)``mapped_column(JSONB, ...)` | JSONB 在 PG 上有 GIN 索引和操作符支持,是 PG 核心优势之一。SQLite 上 `JSON` 实际是 TEXT,切到 PG 后用 `JSON` 类型存的是 `json` 不是 `jsonb`,查询/索引能力差一大截 |
| `app/db/session.py``create_engine` 调用 | 增加连接池参数:`pool_size=10, max_overflow=20, pool_recycle=3600` | SQLite 单文件不需要池,PG 必须显式池化。`pool_recycle=3600` 防 PG 主动断开的 idle 连接 |
| `alembic/versions/` 下旧迁移里的 `sa.text('(CURRENT_TIMESTAMP)')` | **不要改** | PG 能接受这个语法。改了反而搞乱迁移历史 + 让 alembic 觉得 schema drift。新写的迁移统一用 `sa.func.now()` |
### 3.2 保留的 dialect 特判
下面这些**保留原样**,它们的 SQLite 分支在 PG 下自动绕过、不影响新部署,但本地开发想用 SQLite 临时跑还能用:
- `app/db/session.py` 里的 `_ensure_sqlite_dir``check_same_thread` 特判
- `alembic/env.py` 里的 `render_as_batch` 特判
### 3.3 顺便清掉
`app/main.py` 启动日志里如果打 `DATABASE_URL`,确认密码不会进日志(只打 scheme + host,不打完整 URL)。
### 3.4 验证
本地 PG 起 `./run.sh`,跑一遍下面的烟测脚本:
```bash
# 登录
curl -X POST http://localhost:8770/api/v1/auth/sms/send -d '{"phone":"13800138000"}'
curl -X POST http://localhost:8770/api/v1/auth/sms/login -d '{"phone":"13800138000","code":"123456"}'
# 拿到 access_token 后调 me
curl -H "Authorization: Bearer <token>" http://localhost:8770/api/v1/auth/me
# 美团 feed
curl -X POST http://localhost:8770/api/v1/meituan/feed -d '{"longitude":116.4,"latitude":39.9,"page":0}'
```
三条都 200 即通过。`psql``user` 表多了一条 `13800138000`
---
## 4. 生产切换(半小时,无需停服窗口)
**前提**:生产无真实数据。如已有数据,本节不适用。
### 4.1 装 PG
```bash
ssh server
sudo apt install postgresql-16
sudo systemctl enable --now postgresql
```
### 4.2 建库
```bash
sudo -u postgres psql <<'EOF'
CREATE USER shaguabijia_app WITH PASSWORD '<strong-random-password>';
CREATE DATABASE shaguabijia OWNER shaguabijia_app ENCODING 'UTF8';
GRANT ALL PRIVILEGES ON DATABASE shaguabijia TO shaguabijia_app;
EOF
```
生产密码**单独生成**,不要复用本地的。
### 4.3 装驱动到生产 venv
```bash
cd /opt/shaguabijia-app-server
.venv/bin/pip install "psycopg[binary]>=3.1"
```
### 4.4 改生产 `.env`
```ini
DATABASE_URL=postgresql+psycopg://shaguabijia_app:<password>@localhost:5432/shaguabijia
```
### 4.5 建表 + 重启
```bash
.venv/bin/alembic upgrade head
sudo systemctl restart shaguabijia-app-server
sudo journalctl -u shaguabijia-app-server -f
```
启动日志里看到 `Application startup complete` + 没有 5xx 即通过。
### 4.6 验证
- 用真账号(手机号)走一遍极光登录或短信登录
- 调 `GET /api/v1/auth/me`,确认返回当前用户
- 调 `POST /api/v1/meituan/feed`,确认券列表正常
- `psql -U shaguabijia_app -d shaguabijia -c 'SELECT id, phone, created_at FROM "user"'` 看到刚登录的用户
### 4.7 老 SQLite 文件归档
```bash
cd /opt/shaguabijia-app-server
mv data/app.db data/app.db.legacy-$(date +%Y%m%d)
```
不要删——万一要回查测试期残留数据(开发期可能塞了一些手工记录),保留几个月。
---
## 5. 兜底:切换失败如何回滚
万一改完起不来:
```bash
# 1. .env 改回 SQLite
sed -i 's|^DATABASE_URL=postgresql.*|DATABASE_URL=sqlite:///./data/app.db|' /opt/shaguabijia-app-server/.env
# 2. 把归档的 sqlite 文件还原(如果已经改名)
mv data/app.db.legacy-* data/app.db
# 3. 重启
sudo systemctl restart shaguabijia-app-server
```
由于 SQLite 文件没动过,无数据丢失,损失只是切换过程中的几分钟 5xx。
---
## 6. 顺便建议:同时装上 Redis
PG 上线后,Redis 是下一个明确要补的基础设施(已在 [待办与技术债.md](./待办与技术债.md) 里列了用途)。**建议本次维护窗口顺手把 Redis 也装上**,不开始用、只装服务,免得下次还要单独申请部署:
```bash
sudo apt install redis-server
sudo systemctl enable --now redis-server
# 改 /etc/redis/redis.conf:bind 127.0.0.1(只本机访问)+ requirepass <strong-password>
sudo systemctl restart redis-server
```
下面三个用途等接的时候再写代码,**本次只装服务、不接代码**:
| 用途 | 当前临时方案 | 何时必须接 Redis |
|---|---|---|
| 短信验证码冷却 | 进程内存 dict(`integrations/sms.py`) | `SMS_MOCK=false` 上线时 / 扩 worker 时 |
| pricebot trace_id session | 进程内存 dict(在 pricebot-backend) | pricebot 扩 worker 时 |
| JWT 黑名单(logout 真失效) | 当前 logout 占位无失效 | P1 鉴权改造时 |
---
## 7. 后续优化(不阻塞上线,一周内补)
- **每天凌晨 `pg_dump` 备份**:用 systemd timer 或 cron,保留 7 天。备份文件 `pg_dump -Fc shaguabijia > /backup/shaguabijia-$(date +%Y%m%d).dump`
- **金钱表加 CHECK 约束**:新迁移给 `coin_account.balance``withdraw_order.amount_yuan_x100``CHECK (... >= 0)`。PG 严格执行,SQLite 形同虚设
- **timezone 统一**:生产 PG 配 `timezone='Asia/Shanghai'` 或维持 `UTC`,**团队定一个**写进本文档,业务代码统一处理。当前模型混用 `func.now()`(server-side)和 `_utcnow()`(`models/user.py``last_login_at`,Python-side),后者依赖应用层时区,迁完 PG 后建议统一改 `func.now()`
- **慢查询观察**:`shared_preload_libraries = 'pg_stat_statements'` 打开扩展,每周看一次 Top 10 慢查询,提前优化
---
## 8. 不要做的事
- ❌ 不要搞主从复制 / 读写分离——量级远没到,先用单实例
- ❌ 不要装 PgBouncer / 连接池中间件——SQLAlchemy 自带池够用
- ❌ 不要 squash 旧迁移文件——9 个增量保持原样,让 alembic 一次跑过空库
- ❌ 不要在生产 PG 上裸跑测试套件——用单独的 `shaguabijia_test`
+86 -28
View File
@@ -3,21 +3,27 @@
> 域名:`app-api.shaguabijia.com`(HTTPS,nginx 反代)
> 仓库:`shaguabijia-app-server`
> 接口协议详见 [`docs/api/`](./api/)(索引 + 一接口一文件)
> 最后更新:2026-05-27
> 最后更新:2026-05-31
---
## 1. 这个后端是干什么的
为正式版 App(`shaguabijia-app-android`,包名 `com.jishisongfu.shaguabijia`)提供三块能力:
为正式版 App(`shaguabijia-app-android`,包名 `com.jishisongfu.shaguabijia`)提供以下能力:
| 能力 | 说明 |
|---|---|
| **账号与登录** | 极光一键登录 + 短信验证码登录(mock)→ 签发 JWT |
| **美团 CPS 选品** | 透传美团联盟优惠券(外卖/到店)、点击换取推广链接(分佣) |
| **用户资料** | 昵称 / 头像(上传图片含魔数嗅探) / 注销账号(软删除+匿名化) |
| **美团 CPS 选品** | 透传美团联盟优惠券(外卖/到店)、点击换取推广链接(分佣)、未配凭证时降级返空 |
| **领券透传** | `/coupon/step` 透传到 pricebot-backend(一键领券核心,MVP 不鉴权,前端已接通) |
| **外卖比价透传** | `/intent/recognize` + `/price/step` 透传到 pricebot-backend(food MVP,MVP 不鉴权) |
| **金币 / 现金钱包** | 金币账户/流水/兑换/微信绑定/提现单 11 端点 |
| **签到 + 任务 + 省钱战绩** | 福利模块 |
| **看广告发奖** | 穿山甲 GroMore 激励视频 S2S 回调(SHA256 验签)+ 4 态 CTA 冷却 |
| **帮助与反馈** | 用户提交反馈(含截图)+ 静态 `/media` 服务 |
**本服务自身没有"比价"实现**——名字叫比价,实质是:登录态 + 美团券聚合分佣 + 给真正的领券/比价核心(pricebot-backend,另一个 repo)做"透传壳"。无爬虫、无 LLM、无积分/提现/钱包,数据模型仅一张 `user` 表(美团数据与领券请求均实时透传不落库)。
**本服务自身没有"比价"实现**——比价/领券业务的核心都在 pricebot-backend(另一个 repo),本服务对 step 类接口做"透传壳"。无爬虫、无 LLM,但**有钱包/福利等业务模型**(数据模型从早期 1 张 `user` 表已扩展到 10 张,见 §7)。
---
@@ -30,7 +36,8 @@
| ASGI | uvicorn[standard] | 生产 `--host 127.0.0.1 --port 8770` |
| ORM | SQLAlchemy 2.0(Mapped 风格) | |
| 迁移 | Alembic | |
| DB | SQLite 起步 | 改 `DATABASE_URL` 可切 PostgreSQL,无 Redis |
| DB | **PostgreSQL 16(生产)/ SQLite(开发兜底)** | 一键脚本 `scripts/init_postgres.py` 建库 + 写 `.env` + 跑迁移;开发环境改 `DATABASE_URL=sqlite:///./data/app.db` 即回退。无 Redis。详见 [docs/postgres-migration.md](./postgres-migration.md) |
| DB 驱动 | psycopg3(`psycopg[binary]>=3.1`) | `DATABASE_URL=postgresql+psycopg://...`,**不要装 psycopg2**——SQLAlchemy 2.0 时代默认 psycopg3 |
| Auth | PyJWT(HS256) | access + refresh |
| 极光 | httpx + cryptography | REST 验 token + RSA 解密手机号 |
| 美团 / pricebot | httpx(含 async) | 美团 S-Ca 网关签名;领券 async 透传 |
@@ -50,18 +57,24 @@ app/
│ ├── deps.py # 共享依赖:get_current_user(鉴权)、get_db(注入 session)
│ └── v1/ # 接口层(薄):解析请求 → 调 repositories/integration → 组装响应 + HTTP 错误码
│ ├── auth.py # 登录 6 端点(极光一键登录 / 短信 send+login / refresh / me / logout)
│ ├── user.py # 用户资料 3 端点(改昵称 / 上传头像 / 注销账号)
│ ├── feedback.py # 帮助与反馈 1 端点(提交反馈含截图)
│ ├── coupon.py # 领券透传 /coupon/step(转发 pricebot,MVP 不鉴权)
│ ├── compare.py # 外卖比价透传 /intent/recognize + /price/step(转发 pricebot,MVP 不鉴权)
│ ├── meituan.py # 美团 3 端点 + feed 拼接(_interleave / _TOPIC_ROUNDS)
│ ├── compare_record.py# 比价记录 3 端点(上报 /compare/record + 列表 /compare/records + 详情;鉴权,区别于上面透传)
│ ├── meituan.py # 美团 3 端点 + feed 拼接(_interleave / _TOPIC_ROUNDS),未配 MT_CPS 凭证降级返空
│ ├── wallet.py # 钱包/提现 11 端点(余额/流水/兑换/绑微信/提现/查单)
│ ├── signin.py # 签到 2 端点(状态 / 执行签到)
│ ├── tasks.py # 一次性任务 2 端点(列表 / 领取)
│ ├── savings.py # 省钱 3 端点(汇总 / 战绩 / 明细)
│ └── ad.py # 看广告发奖 3 端点(穿山甲 S2S 回调 / 进度 / 联调发奖)
│ └── ad.py # 看广告发奖 3 端点(穿山甲 S2S 回调 / 进度+本轮冷却 / 联调发奖)
├── schemas/ # Pydantic:API 收发的数据契约(与客户端对齐字段看这里)
│ ├── auth.py
│ ├── user.py # 改昵称请求 + OkResponse
│ ├── feedback.py # 反馈出参(请求是 multipart,在 router 直接校验)
│ ├── meituan.py
│ ├── welfare.py # 钱包/签到/任务/省钱 收发模型
│ ├── compare_record.py # 比价记录上报/列表/详情 收发模型(字段对齐 pricebot calibration + done.params)
│ └── ad.py # 看广告发奖收发模型
├── integrations/ # 外部服务/SDK 客户端(重逻辑:签名/加解密/外部 HTTP)
│ ├── jiguang.py # 极光 REST 验 token + RSA 解密(多 padding 试错)
@@ -73,35 +86,47 @@ app/
│ ├── config.py # pydantic-settings
│ ├── security.py # JWT 签发/校验
│ ├── ratelimit.py # 同 IP 滑动窗口限流依赖
│ ├── rewards.py # 发奖/兑换/提现额度等业务常量与换算
│ ├── rewards.py # 发奖/兑换/提现额度等业务常量与换算(2026-05 加 VIDEO_ROUND_REQUIRED_COUNT / VIDEO_ROUND_COOLDOWN_SECONDS)
│ ├── media.py # 用户上传文件(头像/反馈截图)落盘 + 魔数嗅探 + 随机文件名
│ └── logging.py
├── repositories/ # 数据访问 + 事务(早期叫 crud,2026-05 统一并入此目录)
│ ├── user.py # get_user_by_id / get_user_by_phone / upsert_user_for_login
│ ├── user.py # get_user_by_id / by_phone / upsert_for_login / update_nickname / set_avatar_url / soft_delete_account
│ ├── feedback.py # 提交反馈写库
│ ├── wallet.py # 账户/流水/兑换/提现单(调 integrations/wxpay)
│ ├── signin.py # 签到记录 / 连续天数 / 档位
│ ├── task.py # 一次性任务领取
│ ├── savings.py # 省钱汇总 / 战绩 / 明细
── ad_reward.py # 看广告发奖(按 trans_id 幂等 + 每日上限)
── comparison.py # 比价记录 upsert(user_id+trace_id 幂等)+ best/saved/status 派生 + 分页
│ └── ad_reward.py # 看广告发奖(按 trans_id 幂等 + 每日上限 + 本轮冷却派生)
├── models/ # ORM 表结构
│ ├── user.py # user(含微信 openid/nickname/avatar)
│ ├── feedback.py # 用户反馈(content/contact/images JSON 列/status)
│ ├── wallet.py # 金币账户 / 金币流水 / 现金流水 / 提现单
│ ├── signin.py # 签到记录
│ ├── task.py # 任务领取记录
│ ├── savings.py # 省钱明细 / 店铺菜品
│ ├── savings.py # 省钱明细 / 店铺菜品 / dishes(PG 上 JSONB)
│ ├── comparison.py # 比价记录(完整明细;含 4 个 JSON(B) 列 + raw_payload;独立于 savings)
│ └── ad_reward.py # 看广告发奖记录
└── db/
├── base.py # DeclarativeBase
└── session.py # engine + get_db
└── session.py # engine + get_db(非 SQLite 时启 pool: size=10/overflow=20/recycle=3600)
alembic/ # 数据库迁移(versions/ 9 个迁移;文件名已去 hex 前缀,链靠文件内 down_revision)
alembic/ # 数据库迁移(versions/ 11+ 个迁移含 feedback_table / convert_dishes_jsonb / merge 等)
deploy/ # systemd(.service) + nginx(.conf)
secrets/ # 极光 RSA 私钥 / 微信支付证书(不入 git,仅 .gitkeep 占位)
scripts/ # 运维脚本(migrate 迁移 / 对账 / 重置签到 / 重置福利 / 模拟穿山甲回调)
tests/ # pytest(auth / health / welfare / withdraw / ad_reward / coupon_proxy)
scripts/
├── init_postgres.py # 一键 PG 初始化:建用户 + 建库 + 写 .env + 跑迁移(2026-05 新增,见已知 bug §10)
├── migrate.sh # 单独跑 alembic upgrade head(部署/CI 用)
├── reset_signin.py # 重置今日签到
├── reset_welfare.py # 重置福利数据
├── reconcile_withdraws.py # 提现对账
└── sim_pangle_callback.py # 模拟穿山甲回调
tests/ # pytest(auth / health / welfare / withdraw / ad_reward / coupon_proxy / compare_proxy)
run.sh # 本地启动脚本(自动先跑迁移再起服务)
docs/api/ # API 接口文档(索引 README + 一接口一文件)
docs/integrations/ # 集成层实现文档(SDK 签名/加解密/协议细节)
docs/数据库迁移.md # Alembic 迁移指南(如何建表/升级/新增迁移)
docs/postgres-migration.md # SQLite → PostgreSQL 切换指南(配套 scripts/init_postgres.py)
```
> **命名说明**:`api/v1/``v1` 用于 URL 版本化(移动端无法强制即时升级,需新旧版本并存能力);`integrations` 装外部 SDK 集成、`repositories` 装数据访问、`core` 装基础设施,三者分离。**数据访问层统一在 `repositories/`**(早期叫 `crud/`,2026-05 已整体并入,`crud/` 不再存在)。`coupon.py` 是领券透传,勿与 `meituan.py` 里的 `coupons`(券列表)混淆。
@@ -197,41 +222,71 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
## 7. 数据模型
仅一张 `user` 表(`models/user.py`):
业务表(下表)+ `alembic_version` 框架表。生产 PG / 开发可回退 SQLite。
| 表 | models 文件 | 说明 |
|---|---|---|
| `user` | `models/user.py` | 用户(phone 唯一索引;注销时改成 `deleted_<id>` 释放约束) |
| `feedback` | `models/feedback.py` | 用户反馈(content/contact/images JSON 列/status) |
| `coin_account` | `models/wallet.py` | 用户的金币余额(`balance_cents` 单位:分) |
| `coin_transaction` | `models/wallet.py` | 金币流水(签到/任务/兑换/广告;`type` + `amount_cents`) |
| `cash_transaction` | `models/wallet.py` | 现金流水(兑换/提现) |
| `withdraw_order` | `models/wallet.py` | 提现单(`status` + `wxpay_batch_id`,微信支付商家转账) |
| `signin_record` | `models/signin.py` | 签到记录(每日一行,索引 `(user_id, signin_date)`) |
| `user_task` | `models/task.py` | 一次性任务领取记录 |
| `savings_record` | `models/savings.py` | 省钱明细(`dishes` 在 PG 上为 JSONB,SQLite 上是 TEXT) |
| `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 表字段速查**:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INTEGER PK | 自增 |
| phone | VARCHAR(20) UNIQUE | 登录主键,唯一索引 `ix_user_phone` |
| phone | VARCHAR(20) UNIQUE | 登录主键(注销后变 `deleted_<id>` 占位) |
| register_channel | VARCHAR(20) | `jverify` / `sms` |
| nickname | VARCHAR(64) NULL | 预留,**无接口可写,恒为 null** |
| avatar_url | VARCHAR(512) NULL | 同上 |
| nickname | VARCHAR(64) NULL | `PATCH /api/v1/user/profile` 写入 |
| avatar_url | VARCHAR(512) NULL | 相对路径(`/media/avatars/...`),由 `POST /api/v1/user/avatar` 写入 |
| wechat_openid | VARCHAR(64) NULL UNIQUE | 微信绑定(提现用),一个微信只绑一个账号 |
| wechat_nickname / wechat_avatar_url | VARCHAR NULL | 微信侧资料(绑定时拉取) |
| status | VARCHAR(20) | `active` / `disabled` / `deleted`,仅 active 可登录/鉴权 |
| created_at / last_login_at | DATETIME(tz) | |
`upsert_user_for_login`:phone 存在则更新 `last_login_at`,不存在则注册(注册即登录)。Alembic 实际库里另有框架表 `alembic_version`
`upsert_user_for_login`:phone 存在则更新 `last_login_at`,不存在则注册(注册即登录)。
**Alembic 迁移**:`alembic/versions/` 当前 11 个迁移 + 1 个合并迁移(`f01db5d77dac_merge_pg_jsonb_and_feedback_heads.py`,2026-05-29 创建——实习生 PG 分支和 user-profile-feedback 分支并行开发时 down_revision 都挂在 `c8d9e0f1a2b3` 上,导致多 head,合并迁移把两条链合并)。详见 [数据库迁移.md](./数据库迁移.md)。
---
## 8. 配置与部署
配置见 `core/config.py`(pydantic-settings 读 `.env`)。分组:环境、`DATABASE_URL`、JWT、极光(`JG_*`)、短信(`SMS_MOCK` 默认 true)、美团(`MT_CPS_*`)、**pricebot 上游(`PRICEBOT_BASE_URL` / `PRICEBOT_REQUEST_TIMEOUT_SEC`)**、CORS。
配置见 `core/config.py`(pydantic-settings 读 `.env`)。分组:环境、`DATABASE_URL`(默认 SQLite,生产应切 PG)、JWT、极光(`JG_*`)、短信(`SMS_MOCK` 默认 true)、美团(`MT_CPS_*`)、**pricebot 上游(`PRICEBOT_BASE_URL` / `PRICEBOT_REQUEST_TIMEOUT_SEC=30` / `PRICEBOT_COMPARE_TIMEOUT_SEC=60`)**、**媒体存储(`MEDIA_ROOT=./data/media` / `MEDIA_URL_PREFIX=/media`)**、CORS。
**生产部署**:systemd `shaguabijia-app-server.service`(WorkingDirectory `/opt/shaguabijia-app-server`,`EnvironmentFile=.env`,uvicorn 监听 `127.0.0.1:8770`,`--workers 1`)+ nginx 443 反代 → 8770。**无 Docker**。
**生产部署**:systemd `shaguabijia-app-server.service`(WorkingDirectory `/opt/shaguabijia-app-server`,`EnvironmentFile=.env`,uvicorn 监听 `127.0.0.1:8770`,`--workers 1`)+ nginx 443 反代 → 8770。**无 Docker**。**PostgreSQL 16** 同机部署。
```bash
# 首次 PG 初始化(新机器或新环境):
ssh server "sudo apt install -y postgresql-16 && sudo systemctl enable --now postgresql"
ssh server "cd /opt/shaguabijia-app-server && .venv/bin/python scripts/init_postgres.py"
# 该脚本会建业务用户 + 建库 + 写 .env 的 DATABASE_URL + 跑 alembic upgrade head
# 后续日常部署:
rsync -avz --exclude='.venv' --exclude='__pycache__' --exclude='data' --exclude='secrets/*.pem' ./ server:/opt/shaguabijia-app-server/
scp secrets/jverify_rsa_private.pem server:/opt/shaguabijia-app-server/secrets/ # 私钥单独传,不入 git
ssh server "cd /opt/shaguabijia-app-server && .venv/bin/alembic upgrade head && systemctl restart shaguabijia-app-server"
```
完整 PG 切换流程见 [docs/postgres-migration.md](./postgres-migration.md)。
**生产 checklist(均为上线必查)**:
- [ ] `JWT_SECRET_KEY` 改为高熵随机串——默认值 `change-me` 可被伪造 token
- [ ] `SMS_MOCK=false` 并接真实短信供应商——mock 下任意 6 位码可登录任意手机号
- [ ] `MT_CPS_APP_KEY` / `MT_CPS_APP_SECRET` 已填(否则美团接口 502)
- [ ] `MT_CPS_APP_KEY` / `MT_CPS_APP_SECRET` 已填(未配凭证时美团 3 端点降级返空,前端首页空白)
- [ ] `PRICEBOT_BASE_URL` 指向真实 pricebot-backend(否则领券 502)
- [ ] RSA 私钥就位且与极光控制台公钥**配对**,权限 600
- [ ] **DATABASE_URL 已切 PG**(默认 SQLite 单文件并发写锁库,扩 worker 立即撞墙)
- [ ] **psycopg3 已装**(`pip install "psycopg[binary]>=3.1"`)
- [ ] `MEDIA_ROOT` 目录可写(头像/反馈截图落盘);建议 nginx 直接 serve 该目录绕过应用进程
- [ ] `APP_ENV=prod``APP_DEBUG=false`、nginx SSL 有效、`alembic upgrade head` 已执行
---
@@ -255,12 +310,15 @@ conda activate price # 首次:pip install -e .
| 项 | 说明 |
|---|---|
| logout 无服务端失效 | 靠客户端清 token;后续加 jti 黑名单表 |
| logout 无服务端失效 | 靠客户端清 token;后续加 jti 黑名单表(注销账号也是同问题——软删后旧 token 仍能用到自然过期) |
| 美团接口无鉴权 + sid 可覆盖 | 评估加鉴权/锁定 sid(注意首页要求未登录可见) |
| feed 静默吞异常 | 建议给 `_fetch_topic` 加日志,避免无声失败 |
| 领券依赖 pricebot | `coupon/step` 仅透传,真正逻辑在 pricebot-backend;前端已接通领券链路 |
| 美团接口未配凭证降级 | 未配 `MT_CPS_APP_KEY` 时 3 端点返空(不报 502),`/feed` 跟"已配但调用失败"路径无法区分——见 [integrations/meituan](./integrations/meituan.md) |
| 领券/比价依赖 pricebot | `coupon/step` / `intent/recognize` / `price/step` 仅透传,真正逻辑在 pricebot-backend;前端已接通领券链路,比价 food MVP 也已接通 |
| agent 系列接口 MVP 不鉴权 | 拿不到 user_id → 无法采集"哪个用户领了/买了什么"用户级画像(商业模式核心资产)。见 [待办与技术债.md](./待办与技术债.md) P1 |
| SMS 为 mock | 上线接真实供应商 + `SMS_MOCK=false` |
| 短信冷却存内存 | 扩 worker 前需迁移到 Redis |
| SQLite | 流量上来切 PostgreSQL,只改 `DATABASE_URL` |
| `MEDIA_ROOT` 进程内 serve | 头像/反馈截图当前用 FastAPI StaticFiles,生产建议 nginx 直 serve 该目录 |
| `init_postgres.py` 已知小 bug | 5 条小坑,见 [待办与技术债.md](./待办与技术债.md) |
| Alembic 多 head 风险 | 跨分支并行开发要在 PR 合并前 `alembic heads` 检查只有一个;漏检会出现 "Multiple head revisions",需要 `alembic merge` 合并 |
> 完整接口协议(11 个)见 [`docs/api/`](./api/),以代码为准。
> 完整接口协议见 [`docs/api/`](./api/)(38 个端点 + `/health` + `/media` 静态服务),以代码为准。
+45 -3
View File
@@ -3,7 +3,7 @@
> 用途:记"现在先简化、以后要补"的事,和暂不处理的技术债,免得忘。每条标优先级 + 触发背景。做掉的移到末尾「已解决」。
> 范围:跨栈账本——本仓 `shaguabijia-app-server`(转发层) + `shaguabijia-app-android`(无障碍引擎移植) + `pricebot-backend`(agent 上游,不改)。
> 大背景:把"后端当大脑、无障碍服务遥控前端操作其它 App"的比价/领券引擎,从 demo app `pricebot` 移植进正式版傻瓜比价。引擎移植采用「代码全搬、独立子包 `…shaguabijia.agent.*`、验证分步(先领券后比价)」。
> 最后更新:2026-05-27
> 最后更新:2026-05-31
---
@@ -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`),可作透传链路补鉴权的参考样板。
---
@@ -44,6 +44,39 @@
---
## P2 · DB 与 Alembic 治理(2026-05-29 新增)
### Alembic 多 head 风险 — 团队流程问题
**触发背景**:2026-05-29 用 PG 跑 `alembic upgrade head` 报 "Multiple head revisions are present"。
- 实习生的 `feat/postgres-migration-and-bootstrap-script` 分支基于 `4d444b7` (已合 PR #4 feedback) 创建,但新迁移 `ef96beb47b1e``down_revision` 挂在 `c8d9e0f1a2b3` (ad_reward),跟 PR #4 引入的 `d1e2f3a4b5c6` (feedback) 并行成两个 head
- **根因**:跨分支并行开发,PR 合并时没人检查 alembic 迁移链是否线性
**临时修复**:`alembic merge -m "merge pg_jsonb and feedback heads" ef96beb47b1e d1e2f3a4b5c6` 生成合并迁移 `f01db5d77dac_merge_pg_jsonb_and_feedback_heads.py`(已合进 main)。
**团队规约(待写进 CONTRIBUTING)**:
- 新建包含 alembic 迁移的 PR 前,在分支上跑 `alembic heads`,确认**只有一个 head**;有多个就先 rebase 自己的迁移文件的 `down_revision`
- review 别人含 alembic 迁移的 PR 时,同样检查 heads 数
- 合并 main 之后再跑一次 `alembic heads` 兜底——本次撞到就是 review 没拦住
### `init_postgres.py` 已知小 bug(2026-05-29 review)
`scripts/init_postgres.py` 是实习生为 PG 切换写的 bootstrap 脚本(建用户/库/写 .env/跑迁移),有 5 处可改进项:
1. **`if val:` 把空字符串当未设** → `PG_SUPER_PASS=""` 不能传(本机 trust 认证想跳过密码就只能给占位字符串)
2. **用户自定义 `APP_DB_PASS` 含 `@:/` 会破坏 URL** → 应 `urllib.parse.quote(db_pass, safe='')`(自动生成的 `secrets.token_urlsafe` 是 URL 安全字符,但用户传值不一定)
3. **subprocess 跑 alembic 没显式注 `DATABASE_URL`** → 父 shell 残留旧值会覆盖刚写入 .env 的新值,可能把表建到错的库
4. **强制 TCP localhost** → macOS Homebrew(默认 trust)和 Linux apt(默认 peer)的默认认证模式都不直接走 TCP,新机首次跑大概率失败
5. **承诺的 JSONB GIN 索引没真加** → commit message 和文档都说"PG 上可建 GIN 索引",但 `savings_record.dishes` 实际没加;要么补 `CREATE INDEX ... USING GIN (dishes)`,要么把"GIN"措辞从理由里删掉
短期不阻塞使用(熟手按预期参数能跑通),但累积起来会让新人踩坑。低优先级清理。
### `MEDIA_ROOT` 进程内 serve
`/media` 路径当前由 FastAPI StaticFiles 挂载 serve(头像/反馈截图)。生产建议改 nginx 直 serve `MEDIA_ROOT` 目录,绕过应用进程减少压力。一行 `location /media/ { alias /opt/shaguabijia-app-server/data/media/; }` 即可。
---
## 阶段 1 领券落地后的遗留(待清理,不阻塞跑通)
- **FloatingButton 死代码**:Running 面板已换成 ComposeView 承载 `CouponProgressPanel`(=复用 AgentFloat)。旧原生面板字段(`line1~4View`/`elapsedView`/`progressBarView`/`historyContainerView` 等)+ `updateStatus(StepStatus)` 里保留的那段更新逻辑 + `appendActionLine`/`markCurrentActionDone`/`markPlatformDone`/`addCancelButton` 全部 no-op,待删。
@@ -54,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/` 前缀。
@@ -61,3 +98,8 @@
- ✅ **无障碍身份统一傻瓜比价**:服务名/图标继承 app;`accessibility_service_description`/前台通知/`GuideCopy`/引导 Activity 里的 "PriceBot" 全改「傻瓜比价」;app 图标换成完整 `logo1`(adaptive 居中+黄边,不裁切)。
- ✅ **GuideOverlayService 组件名 bug**:批量 sed 改包名误把无障碍服务 ComponentName 的 packageName 改成 `…shaguabijia.agent`(应是 applicationId `…shaguabijia`)→ 无障碍开启检测永远 false;改用 `PermissionHelper.isAccessibilityEnabled` 动态构造。
- ✅ **BuildConfig 包名**:`com.pricebot.app.BuildConfig` import 改本 app 包名。
- ✅ **SQLite → PostgreSQL 切换**(2026-05-29,PR #6 + alembic 合并迁移):新增 `scripts/init_postgres.py` 一键脚本、`psycopg[binary]>=3.1` 依赖、`savings_record.dishes` 列改 JSONB、连接池参数(`pool_size=10/overflow=20/recycle=3600`)。SQLite 仍可作开发兜底(只改 `DATABASE_URL`)。详见 [postgres-migration.md](./postgres-migration.md)。
- ✅ **外卖比价透传 2 端点**(2026-05-27,`feat(compare)`):`/api/v1/intent/recognize` + `/api/v1/price/step``compare.py`,纯 body 透传到 pricebot-backend,`PRICEBOT_COMPARE_TIMEOUT_SEC=60` 独立超时。电商 2 个待接(`compare.py` 加两行即可)。
- ✅ **用户资料 + 帮助反馈接口**(2026-05-28,PR #4):`PATCH /api/v1/user/profile` 改昵称、`POST /api/v1/user/avatar` 上传头像(魔数嗅探)、`DELETE /api/v1/user` 注销账号(软删+匿名化)、`POST /api/v1/feedback` 提交反馈(含截图);新建 `feedback` 表 + `core/media.py` + `/media` 静态服务。
- ✅ **美团 3 端点未配凭证降级**(2026-05-28,`feat(meituan)`):未配 `MT_CPS_APP_KEY``coupons`/`feed`/`referral-link` 直接返空(不再 502),解决新开发机首屏炸的问题。**坏副作用**:`/feed` 跟"已配但调用失败"路径在响应上无法区分(都是 200 + 空 items),靠 `/health``mt_cps_configured` 字段区分。
- ✅ **看广告 4 态 CTA + 本轮冷却**(2026-05-29,PR #5):`reward-status``round_count` + `cooldown_until` 字段,客户端 CTA 4 态(Normal/Loading/Capped/CoolingDown)+ 弹窗 limit note("本轮看完"/"今日看完")全由后端权威派生,跨设备/重装/杀进程都一致。**冷却仅 UX**,发奖不受影响。
+76 -20
View File
@@ -2,6 +2,11 @@
> 本项目用 **Alembic** 管理数据库表结构。所有"建表/改表"都写成 `alembic/versions/` 下的迁移脚本,
> 数据库的真实结构 = 把这些迁移按顺序跑一遍的结果。**不要手动改库结构**,一律走迁移。
>
> **相关文档**:
> - 这份文档讲 **alembic 操作**(如何建表/升级/新增迁移/多 head 排查)
> - [postgres-migration.md](./postgres-migration.md) 讲 **从 SQLite 切到 PostgreSQL** 的完整步骤(本机安装/建库/`scripts/init_postgres.py` 用法)
> - 配合 [后端技术实现.md §10](./后端技术实现.md) 看待办与已知问题
---
@@ -12,23 +17,42 @@ alembic upgrade head
```
它会把数据库结构升到最新(幂等,已是最新则什么都不做)。
**新 PG 环境从零开始**:跑 `python scripts/init_postgres.py`,它会建用户 + 建库 + 写 .env + 跑迁移。详见 [postgres-migration.md](./postgres-migration.md)。
---
## 1. 首次初始化(clone 后)
### 1.1 用 PostgreSQL(推荐,与生产一致)
前置:本机已装 PostgreSQL 16 + 知道 postgres 超级用户密码。
```bash
# (1) 装依赖(在你的虚拟环境里)
# (1) 装依赖(在你的虚拟环境里, 含 psycopg)
pip install -e .
# (2) 配 .env(从模板复制,至少填 JWT_SECRET_KEY)
cp .env.example .env
# (2) 一键建用户 + 建库 + 写 .env + 跑迁移
python scripts/init_postgres.py
# 按提示输入 PG 超级用户密码即可。脚本会:
# - 自动建业务用户 shaguabijia_app + 业务库 shaguabijia
# - 自动生成业务用户强密码,写入 .env 的 DATABASE_URL
# - 跑完 alembic upgrade head
# (3) SQLite 默认库需要 data/ 目录存在
# (3) 启动
./run.sh
```
可用环境变量提前指定(免交互):
```bash
PG_SUPER_PASS=xxx APP_DB_PASS=yyy python scripts/init_postgres.py
```
脚本幂等,重复跑会重置业务用户密码、跳过已存在的库。
### 1.2 用 SQLite(本地开发临时用,不接生产链路时)
```bash
pip install -e .
cp .env.example .env # 不动 DATABASE_URL, 默认 sqlite:///./data/app.db
mkdir -p data
# (4) 跑迁移建表 —— 关键
alembic upgrade head
# (5) 启动(run.sh 会自动重跑 3+4,所以平时直接 ./run.sh 也行)
./run.sh
```
> 也可以直接 `bash scripts/migrate.sh` 只做迁移、不启服务(部署/CI 用)。
@@ -68,21 +92,53 @@ alembic upgrade head
- 生成后**手动把文件名前缀去掉**(内部 id 别动);或
- 在 `alembic.ini``file_template = %%(slug)s`(注意 ini 里 `%` 要写成 `%%`)让以后直接生成无前缀文件名(缺点:同名 slug 会冲突,描述写具体点)。
## 6. 当前迁移链9 条)
## 6. 当前迁移链(12 条,含合并迁移)
```
init_user_table ← 起点(down_revision=None)
→ welfare_tables_coin_account_coin_txn 金币账户/金币流水/签到/任务表
→ cash_transaction_table 现金流水表
→ savings_record_table 省钱明细表
→ withdraw_order_and_user_openid 提现单表 + user.wechat_openid
→ user_wechat_nickname_avatar user 微信昵称/头像字段
→ unique_wechat_openid user.wechat_openid 唯一约束
→ savings_shop_dishes 省钱明细 shop_name + dishes
→ ad_reward_record 看广告发奖记录表 (head,最新)
init_user_table ← 起点(down_revision=None)
→ welfare_tables_coin_account_coin_txn 金币账户/金币流水/签到/任务表
→ cash_transaction_table 现金流水表
→ savings_record_table 省钱明细表
→ withdraw_order_and_user_openid 提现单表 + user.wechat_openid
→ user_wechat_nickname_avatar user 微信昵称/头像字段
→ unique_wechat_openid user.wechat_openid 唯一约束
→ savings_shop_dishes 省钱明细 shop_name + dishes
→ ad_reward_record 看广告发奖记录表
→ (此处分叉,两条并行 head)
├→ feedback_table 帮助与反馈表(PR #4)
└→ ef96beb47b1e_convert_savings_record_dishes_from_json dishes 列改 JSONB(PR #6)
→ f01db5d77dac_merge_pg_jsonb_and_feedback_heads (head,最新) 合并两条分叉
```
> 顺序以 `alembic history` 输出为准(按 `down_revision` 链,不是文件名字母序)。
## 7. 踩坑
- **SQLite 找不到目录**:报 `unable to open database file` → 先 `mkdir -p data`
- **多个 head**`alembic heads` 出现 2 个 → 有人并行各加了一条迁移分叉,需 `alembic merge -m "merge" <head1> <head2>` 合并。
### SQLite 找不到目录
`unable to open database file` → 先 `mkdir -p data`
### 多 head 分叉(2026-05-29 撞过)
`alembic upgrade head` 报:
```
Multiple head revisions are present for given argument 'head';
please specify a specific target revision, '<branchname>@head'...
```
**原因**:跨分支并行开发,两条分支都加了新迁移且 `down_revision` 都指向同一个 commit,合并到 main 后形成两条平行链。本次实例:
- PR #4 (feedback) 加了 `d1e2f3a4b5c6`,`down_revision='c8d9e0f1a2b3'` (ad_reward)
- PR #6 (PG/JSONB) 加了 `ef96beb47b1e`,`down_revision='c8d9e0f1a2b3'`(也是 ad_reward) ← 跟 #4 同源,撞车
- 两个 PR 都合进 main 后,`alembic heads` 出现两个
**修复**:
```bash
alembic heads # 列出冲突的两个 head id
alembic merge -m "merge <description>" <head1> <head2>
# 生成一个空迁移文件,把两个 head 合成新的单一 head
git add alembic/versions/<new_merge_file>.py
# commit + push, PR review 后合并到 main
```
**预防(团队规约)**:
- 写迁移的 PR 在提交前跑 `alembic heads`,确认本分支只有一个 head
- review 别人含迁移的 PR 时,看 `down_revision` 是否还是当前 main 的最新——如果上游已经被别人合了新迁移,需要 rebase 修改 `down_revision` 指过去
- main 拉新代码后再跑一次 `alembic heads` 兜底
- **生产库**:不要随意 `downgrade`;新迁移先在本地/测试库验证过再上。生产 `DATABASE_URL` 由部署侧配置,不在本仓库 `.env`
+6 -2
View File
@@ -8,7 +8,8 @@
- **发奖只走服务端**:激励视频播完,穿山甲服务器 S2S 回调后端 `/api/v1/ad/pangle-callback` 发金币。
客户端**不发奖**,`onRewardArrived` 只触发"去后端刷余额"。客户端被破解也刷不到钱。
- 后端发奖**幂等**(按 `trans_id` 去重)+ **每日上限**(`DAILY_AD_REWARD_LIMIT`,按北京时间)。
- 关键常量:`app/core/rewards.py``AD_REWARD_COIN=100``DAILY_AD_REWARD_LIMIT=10`(**占位值,上线前按 eCPM 实测收益重定**)。
- 关键常量:`app/core/rewards.py``AD_REWARD_COIN=100``DAILY_AD_REWARD_LIMIT=10`(**占位值,上线前按 eCPM 实测收益重定**)`VIDEO_ROUND_REQUIRED_COUNT=3``VIDEO_ROUND_COOLDOWN_SECONDS=600`(本轮 3 次后冷却 10 分钟)
- **4 态 CTA**(2026-05-29 PR #5):客户端任务行按钮在 Normal / Loading / Capped / CoolingDown 之间切,**全由后端 `reward-status``round_count` + `cooldown_until` 派生**(权威源),跨设备/重装/杀进程一致。详见 [api/ad-reward-status](./api/ad-reward-status.md)。
---
@@ -72,7 +73,10 @@
- [ ] 真机看完一条**真实**激励视频 → 穿山甲 S2S 回调 → 后端发奖 → 客户端余额真到账
- [ ] **幂等**:同一 `trans_id` 不重复发
- [ ] **每日上限**:超过 `DAILY_AD_REWARD_LIMIT` 返回 capped、不发币、按钮显示「已达上限」
- [ ] **每日上限**:超过 `DAILY_AD_REWARD_LIMIT` 返回 capped、不发币、按钮显示「已达上限」(Capped 态)
- [ ] **本轮冷却**(PR #5):连看 3 张 → 按钮变 CoolingDown 显 MM:SS 倒计时不可点 → 10 分钟后自动恢复 Normal
- [ ] **弹窗 limit note**:`round_count==0 && cooldown_until!=null` → "本轮视频已看完,10分钟后再来";`used_today>=daily_limit` → "今日视频已到限额,明天再来"
- [ ] **跨设备/杀进程一致**:在手机 A 看完 3 张进入冷却 → 手机 B(同账号)登录后 reward-status 也返回相同的 `cooldown_until`(权威源在后端,客户端不存本地)
- [ ] 中途退出广告 → 客户端提示「未看完视频,本次没有奖励哦」,且不发奖
- [ ] 收益明细显示「看广告奖励」(biz_type=`ad_reward`)
+3
View File
@@ -17,6 +17,9 @@ dependencies = [
"sqlalchemy>=2.0.35",
"alembic>=1.13.3",
# PostgreSQL 驱动 (psycopg3, SQLAlchemy 2.0 时代默认, 不要再装 psycopg2)
"psycopg[binary]>=3.1",
# JWT 签名 / 校验
"pyjwt[crypto]>=2.9.0",
+189
View File
@@ -0,0 +1,189 @@
"""Bootstrap PostgreSQL: 建用户 + 建库 + 授权 + 写 .env + 跑迁移。
新机器初始化用前置:已装 PostgreSQL 16 + 知道 postgres 超级用户密码
用法:
python scripts/init_postgres.py
环境变量(可选,不填会交互式问):
PG_HOST PG 主机, 默认 localhost
PG_PORT PG 端口, 默认 5432
PG_SUPER_USER 超级用户, 默认 postgres
PG_SUPER_PASS 超级用户密码 (不填会 getpass 交互输入)
APP_DB_NAME 业务库名, 默认 shaguabijia
APP_DB_USER 业务用户名, 默认 shaguabijia_app
APP_DB_PASS 业务用户密码 (不填会自动生成强密码)
幂等:已存在的用户/库不会重复建,已配的 .env 会被覆盖前提示
"""
from __future__ import annotations
import getpass
import os
import secrets
import subprocess
import sys
from pathlib import Path
try:
import psycopg
from psycopg import sql
except ImportError:
print("❌ 缺 psycopg。先跑: pip install -e .")
sys.exit(1)
ROOT = Path(__file__).resolve().parent.parent
ENV_FILE = ROOT / ".env"
ENV_EXAMPLE = ROOT / ".env.example"
def env_or_input(key: str, prompt: str, default: str = "", secret: bool = False) -> str:
val = os.environ.get(key, "").strip()
if val:
return val
if secret:
return getpass.getpass(f"{prompt}: ").strip()
suffix = f" [{default}]" if default else ""
raw = input(f"{prompt}{suffix}: ").strip()
return raw or default
def ensure_role(conn: psycopg.Connection, user: str, password: str) -> None:
# 注意: CREATE/ALTER USER 不支持参数化密码, 必须用 sql.Literal 内联
with conn.cursor() as cur:
cur.execute("SELECT 1 FROM pg_roles WHERE rolname=%s", (user,))
if cur.fetchone():
print(f" · 用户 {user} 已存在, 重置密码")
cur.execute(
sql.SQL("ALTER USER {} WITH PASSWORD {}").format(
sql.Identifier(user), sql.Literal(password)
)
)
else:
print(f" · 创建用户 {user}")
cur.execute(
sql.SQL("CREATE USER {} WITH PASSWORD {}").format(
sql.Identifier(user), sql.Literal(password)
)
)
def ensure_database(conn: psycopg.Connection, db: str, owner: str) -> None:
with conn.cursor() as cur:
cur.execute("SELECT 1 FROM pg_database WHERE datname=%s", (db,))
if cur.fetchone():
print(f" · 数据库 {db} 已存在")
return
print(f" · 创建数据库 {db} (owner={owner}, encoding=UTF8)")
cur.execute(
sql.SQL("CREATE DATABASE {} OWNER {} ENCODING 'UTF8'").format(
sql.Identifier(db), sql.Identifier(owner)
)
)
def grant_all(conn: psycopg.Connection, db: str, user: str) -> None:
with conn.cursor() as cur:
cur.execute(
sql.SQL("GRANT ALL PRIVILEGES ON DATABASE {} TO {}").format(
sql.Identifier(db), sql.Identifier(user)
)
)
def write_env(database_url: str) -> None:
if not ENV_FILE.exists():
if ENV_EXAMPLE.exists():
ENV_FILE.write_text(ENV_EXAMPLE.read_text(encoding="utf-8"), encoding="utf-8")
print(f" · 从 .env.example 复制出 .env")
else:
ENV_FILE.write_text("", encoding="utf-8")
lines = ENV_FILE.read_text(encoding="utf-8").splitlines()
new_lines = []
replaced = False
for line in lines:
if line.startswith("DATABASE_URL="):
old_url = line.split("=", 1)[1]
if old_url.strip() and old_url.strip() != database_url:
ans = input(f"\n.env 里已有 DATABASE_URL=\n {old_url}\n覆盖吗? [y/N]: ").strip().lower()
if ans != "y":
print(" · 保留原 DATABASE_URL")
new_lines.append(line)
replaced = True
continue
new_lines.append(f"DATABASE_URL={database_url}")
replaced = True
else:
new_lines.append(line)
if not replaced:
new_lines.append(f"DATABASE_URL={database_url}")
ENV_FILE.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
print(f" · 写入 .env -> DATABASE_URL")
def run_alembic_upgrade() -> bool:
print("\n[3/3] 跑 alembic upgrade head")
try:
subprocess.run(
[sys.executable, "-m", "alembic", "upgrade", "head"],
cwd=ROOT,
check=True,
)
return True
except subprocess.CalledProcessError as e:
print(f"❌ alembic 失败: {e}")
return False
def main() -> int:
print("=" * 60)
print("PostgreSQL 初始化脚本 — shaguabijia-app-server")
print("=" * 60)
host = env_or_input("PG_HOST", "PG host", "localhost")
port = env_or_input("PG_PORT", "PG port", "5432")
super_user = env_or_input("PG_SUPER_USER", "PG superuser", "postgres")
super_pass = env_or_input("PG_SUPER_PASS", "PG superuser password", secret=True)
db_name = env_or_input("APP_DB_NAME", "App database name", "shaguabijia")
db_user = env_or_input("APP_DB_USER", "App database user", "shaguabijia_app")
db_pass = os.environ.get("APP_DB_PASS", "").strip()
if not db_pass:
db_pass = secrets.token_urlsafe(32)
print(f" · 自动生成业务用户密码: {db_pass}")
print(f"\n[1/3] 连接 PG (host={host}:{port}, user={super_user})")
try:
conn = psycopg.connect(
host=host, port=int(port), user=super_user, password=super_pass,
dbname="postgres", autocommit=True,
)
except psycopg.OperationalError as e:
print(f"❌ 连不上 PG: {e}")
print(" 检查 1) PG 服务是否在跑 2) 超级用户密码是否正确 3) 端口防火墙是否放行")
return 1
print("\n[2/3] 建用户 + 建库 + 授权")
with conn:
ensure_role(conn, db_user, db_pass)
ensure_database(conn, db_name, db_user)
grant_all(conn, db_name, db_user)
database_url = f"postgresql+psycopg://{db_user}:{db_pass}@{host}:{port}/{db_name}"
write_env(database_url)
if not run_alembic_upgrade():
return 1
print("\n" + "=" * 60)
print("✅ 全部完成")
print(f" DATABASE_URL = {database_url}")
print(" 下一步: 启动服务 -> ./run.sh 或 uvicorn app.main:app --reload --port 8770")
print("=" * 60)
return 0
if __name__ == "__main__":
sys.exit(main())
+1 -1
View File
@@ -84,7 +84,7 @@ def test_callback_uses_reward_amount(client) -> None:
assert _callback(client, _signed(uid, "ra_ok", reward_amount="250")).status_code == 200
assert _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
+136
View File
@@ -0,0 +1,136 @@
"""比价战绩里程碑测试:进度随成功比价数解锁 / 逐档领奖发金币 / 幂等 / 边界。
复用比价记录上报接口造成功记录(每条 trace 一次成功比价),再验里程碑
"""
from __future__ import annotations
from app.core.rewards import RECORD_MILESTONES
def _login(client, phone: str) -> str:
client.post("/api/v1/auth/sms/send", json={"phone": phone})
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
assert r.status_code == 200, r.text
return r.json()["access_token"]
def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def _report_success(client, token: str, trace_id: str) -> None:
"""上报一条成功比价(有非源有效价 → status 派生 success)。"""
payload = {
"trace_id": trace_id,
"business_type": "food",
"store_name": "测试店",
"source_platform_id": "taobao_flash",
"source_price": 30.0,
"comparison_results": [
{"platform_id": "meituan", "platform_name": "美团", "price": 25.0,
"is_source": False, "rank": 1},
{"platform_id": "taobao_flash", "platform_name": "淘宝闪购", "price": 30.0,
"is_source": True, "rank": 2},
],
}
r = client.post("/api/v1/compare/record", json=payload, headers=_auth(token))
assert r.status_code == 200, r.text
def _report_failed(client, token: str, trace_id: str) -> None:
"""上报一条失败比价(只有源 → status 派生 failed,不计入解锁)。"""
payload = {
"trace_id": trace_id,
"business_type": "food",
"comparison_results": [
{"platform_id": "taobao_flash", "platform_name": "淘宝闪购", "price": 30.0,
"is_source": True, "rank": 1},
],
}
r = client.post("/api/v1/compare/record", json=payload, headers=_auth(token))
assert r.status_code == 200, r.text
def test_status_empty(client) -> None:
"""没比价过:成功数 0,所有档 locked,无可领。"""
token = _login(client, "13800003001")
r = client.get("/api/v1/compare/milestones", headers=_auth(token))
assert r.status_code == 200, r.text
d = r.json()
assert d["success_count"] == 0
assert d["claimable_count"] == 0
assert len(d["milestones"]) == len(RECORD_MILESTONES)
assert all(m["state"] == "locked" for m in d["milestones"])
# 第 1 档金币对齐配置
assert d["milestones"][0]["coin"] == RECORD_MILESTONES[0]
assert d["milestones"][0]["milestone"] == 1
def test_unlock_progresses_with_success(client) -> None:
"""成功比价 2 次:前 2 档 active 可领,其余 locked。失败记录不计入。"""
token = _login(client, "13800003002")
_report_success(client, token, "s-1")
_report_success(client, token, "s-2")
_report_failed(client, token, "f-1") # 失败不计
d = client.get("/api/v1/compare/milestones", headers=_auth(token)).json()
assert d["success_count"] == 2
assert d["claimable_count"] == 2
states = [m["state"] for m in d["milestones"]]
assert states[0] == "active"
assert states[1] == "active"
assert states[2] == "locked"
def test_claim_marks_claimed_without_coin(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"] == 0 # 不再发金币
assert res["coin_balance"] == bal_before # 余额不变
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
+204
View File
@@ -0,0 +1,204 @@
"""比价记录测试:上报派生 / 幂等 / 分页 / 详情 / 鉴权 / 失败帧。
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, "coupon_saved": 7.0},
{"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_coupon_saved_passthrough(client) -> None:
"""红包优惠额 coupon_saved 上报→落库→读出;仅目标平台带值,源平台不带(null)。"""
token = _login(client, "13800002010")
rid = client.post(
"/api/v1/compare/record", json=_food_payload("trace-coupon"), headers=_auth(token)
).json()["id"]
d = client.get(f"/api/v1/compare/records/{rid}", headers=_auth(token)).json()
by_pid = {x["platform_id"]: x for x in d["comparison_results"]}
assert by_pid["meituan"]["coupon_saved"] == 7.0 # 目标平台带值
assert by_pid["taobao_flash"].get("coupon_saved") is None # 源平台不带/为 null
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
+105
View File
@@ -0,0 +1,105 @@
"""记账测试:比价后下单上报 → 写 savings_record(source='compare')。
覆盖:上报落库 + 门店/菜品/原价随之入库(user,client_event_id) 幂等
真实(compare)记录优先于 demo seeder省额 = 源平台原价 实付
"""
from __future__ import annotations
def _login(client, phone: str) -> str:
client.post("/api/v1/auth/sms/send", json={"phone": phone})
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
assert r.status_code == 200, r.text
return r.json()["access_token"]
def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def _report_body(**over) -> dict:
body = {
"client_event_id": "evt-1",
"platform": "淘宝闪购",
"platform_package": "com.taobao.taobao",
"pay_channel": "alipay",
"compared_price_cents": 3300,
"paid_amount_cents": 3300,
"device_id": "device_test",
"shop_name": "肯德基宅急送(天北路店)",
"dishes": ["史迪奇玩具套餐 x1"],
"original_price_cents": 3680,
"source_platform_name": "美团",
"source_deeplink": "https://example.com/reorder",
}
body.update(over)
return body
def test_order_report_creates_real_savings(client) -> None:
"""上报一笔 → savings 统计/明细只反映这笔真实数据,含门店/菜品/原价。"""
token = _login(client, "13800002001")
r = client.post("/api/v1/order/report", json=_report_body(), headers=_auth(token))
assert r.status_code == 200, r.text
out = r.json()
assert out["duplicated"] is False
assert out["paid_amount_cents"] == 3300
assert out["platform"] == "淘宝闪购"
# summary:真实优先,只算这一笔(demo 不再混入)
s = client.get("/api/v1/savings/summary", headers=_auth(token)).json()
assert s["order_count"] == 1
assert s["total_saved_cents"] == 380 # 3680 3300
assert s["avg_saved_cents"] == 380
# records:门店/菜品/原价/支付渠道/源平台都在
page = client.get("/api/v1/savings/records", headers=_auth(token)).json()
assert len(page["items"]) == 1
it = page["items"][0]
assert it["shop_name"] == "肯德基宅急送(天北路店)"
assert it["dishes"] == ["史迪奇玩具套餐 x1"]
assert it["order_amount_cents"] == 3300
assert it["saved_amount_cents"] == 380
assert it["original_price_cents"] == 3680
assert it["platform"] == "淘宝闪购"
assert it["pay_channel"] == "alipay"
assert it["source_platform_name"] == "美团"
def test_order_report_idempotent(client) -> None:
"""同 (user, client_event_id) 重复上报:第二次 duplicated=True,不新增。"""
token = _login(client, "13800002002")
body = _report_body(client_event_id="evt-dup")
r1 = client.post("/api/v1/order/report", json=body, headers=_auth(token))
assert r1.json()["duplicated"] is False
r2 = client.post("/api/v1/order/report", json=body, headers=_auth(token))
assert r2.json()["duplicated"] is True
s = client.get("/api/v1/savings/summary", headers=_auth(token)).json()
assert s["order_count"] == 1
def test_real_overrides_demo_seed(client) -> None:
"""先触发 demo seeder(多笔),再上报一笔真实 → 统计切换为只算真实那笔。"""
token = _login(client, "13800002003")
demo = client.get("/api/v1/savings/summary", headers=_auth(token)).json()
assert demo["order_count"] > 1 # demo 是多笔
client.post(
"/api/v1/order/report",
json=_report_body(
client_event_id="evt-real", original_price_cents=5000, paid_amount_cents=4200
),
headers=_auth(token),
)
s = client.get("/api/v1/savings/summary", headers=_auth(token)).json()
assert s["order_count"] == 1 # demo 被忽略
assert s["total_saved_cents"] == 800 # 5000 4200
page = client.get("/api/v1/savings/records", headers=_auth(token)).json()
assert len(page["items"]) == 1
assert page["items"][0]["saved_amount_cents"] == 800
def test_order_report_requires_auth(client) -> None:
r = client.post("/api/v1/order/report", json=_report_body())
assert r.status_code == 401
+16 -4
View File
@@ -12,6 +12,9 @@ from app.core.rewards import (
TASK_ENABLE_NOTIFICATION,
TASK_REWARDS,
)
from app.db.session import SessionLocal
from app.repositories import wallet as crud_wallet
from app.repositories.user import get_user_by_phone
def _login(client, phone: str) -> str:
@@ -130,10 +133,19 @@ def test_exchange_info(client) -> None:
def test_exchange_flow(client) -> None:
"""用任务领够金币 → 兑换 1 元 → 金币扣、现金加 → 现金流水有记录。"""
token = _login(client, "13800001005")
# 领"打开消息提醒"得 10000 金币(= 1 元额度)
client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token))
"""供款够兑换下限的金币 → 兑换 1 元 → 金币扣、现金加 → 现金流水有记录。
打开消息提醒任务已降到 1000 金币(不再 = 兑换下限), 不能再靠领任务供款;
直接 grant_coins 注入 MIN_EXCHANGE_COIN(= COIN_PER_YUAN = 10000)当种子
"""
phone = "13800001005"
token = _login(client, phone)
# 供款: 直接注入 1 元额度金币(替代原先靠 notification=10000 领任务供款)
with SessionLocal() as db:
user = get_user_by_phone(db, phone)
assert user is not None
crud_wallet.grant_coins(db, user.id, MIN_EXCHANGE_COIN, biz_type="test_seed", remark="测试供款")
db.commit()
# 兑换 10000 金币 → 100 分
r = client.post(