Compare commits

...

12 Commits

Author SHA1 Message Date
chenshuobo cf1e5908a3 美团券 ETL:运行锁改用 /tmp(摆脱 data/ 的 root 权限依赖)+ 新增定时任务运维手册
- 锁文件默认改到系统临时目录(任何账号可写),支持 MEITUAN_ETL_LOCK 覆盖;修无 sudo 部署时 data/ 属 root → 每轮 PermissionError、cron 抓不进数据的问题
- 新增 deploy/meituan-etl.md:启动/关闭/终止/代码位置/健康检查/调频率/根因背景
2026-06-10 17:51:55 +08:00
chenshuobo 195fad4511 美团券 ETL:prune 防误删护栏 + 每小时定时部署 + meituan_coupon 表文档
- prune 仅在本轮有入库(total>0)时执行,修上游故障时按 last_seen 清空全表的隐患
- 新增 deploy/meituan-etl.{service,timer}:systemd timer 每小时全量灌库(服务器用)
- 新增 docs/database/meituan_coupon.md(字段核对/索引评估/清理策略)并登记索引
2026-06-10 17:51:55 +08:00
liujiahui 0890e693d7 feat(coupon): 今日跑完整轮领券后端记录 + 查询(首页置灰源) (#34)
新增 coupon_daily_completion 表:按 (device_id, 自然日) 记"今天是否已跑完
整轮领券(到 done 帧)"。客户端据此把首页「去领取」卡置灰、不可点。

口径(用户决策 A 方案):到 done 即算,不管单券成败 —— 失败/跳过常是无障碍/
环境问题,重复点也补不回来。判断维度 device_id,与 engagement/claim 一致。

- model CouponDailyCompletion(uq device+complete_date,仿 CouponPromptEngagement)
- repo has_completed_today / mark_completed_today(幂等 upsert + IntegrityError 兜底)
- schema CouponCompletedTodayOut(completed: bool)
- coupon_step 透传链路在 action.command=="done" 那帧 best-effort 写完成记录
  (pricebot 只在整轮全跑完才保留 command=="done",故无需再判 continue)
- GET /api/v1/coupon/completed-today?device_id= 供首页查询
- alembic 迁移 coupon_daily_completion(down_revision=0cf18d590b1d 当前 head)

合并后需 alembic upgrade head,否则 /completed-today 500。

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

---------

Co-authored-by: no_gen_mu <liujianhishen@gmail.com>
Reviewed-on: #34
Co-authored-by: liujiahui <liujiahui@wonderable.ai>
Co-committed-by: liujiahui <liujiahui@wonderable.ai>
2026-06-10 16:07:33 +08:00
ouzhou 0bddce516f feat(ad): eCPM 单位统一为分 + 新增看广告金币审计接口 (#32)
- eCPM 口径修正:getEcpm 原值是分/千次,新增 parse_ecpm_fen;parse_ecpm_yuan
  改为 ÷100 转元,因子1 阈值按元判档(100/200/400),收益换算用元
- test-grant 用客户端按 ad_session_id 上报的真实 eCPM 发奖(取不到/≤0 兜底 200)
- 新增 GET /admin/api/ad-coin-audit 只读复算对账(router/repo/schema),复用发奖公式
- 同步更新 docs/api、docs/database 的分/元口径

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

---------

Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #32
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-10 15:53:52 +08:00
xiebing 90fe6d6aa7 feat(invite): 邀请落地页改跳应用商店(替代手点右上角下载) (#36)
- 浏览器内:点按钮→写剪贴板归因→跳 market:// 唤起手机自带商店
- market:// 唤不起(2.5s 无响应)→ 兜底跳应用宝网页下载页
- 微信内保留引导去浏览器;iOS 维持"即将上线"
- 仅改 dl.html;App/后端不动,归因+指纹兜底机制不变
- market:// 各厂商兼容性真机待验

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

Reviewed-on: #36
Co-authored-by: xiebing <xiebing@wonderable.ai>
Co-committed-by: xiebing <xiebing@wonderable.ai>
2026-06-10 15:53:14 +08:00
marco be5e94ed6d fix(alembic): 线性化 f8d3b1e60a27 接 a8c47fc4dc39,消除与 #33 mergepoint 平行的双 head
f8d3b1e60a27 最初基于旧 main(#31 双 head)写、down_revision 指向那对父;但 #33 已用 a8c47fc4dc39 合并那对 head,合入 main 后 a8c47 与 f8d3 平行 → 又成双 head,alembic upgrade head 报 Multiple heads、deploy 迁移会失败。
改 f8d3 的 down_revision 为 a8c47fc4dc39(线性接其后、只加两列),收口单 head。已 SQLite upgrade head 验证整链可 apply。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:43:54 +08:00
marco 953a05a5e6 feat(debug-trace): 调试链接权限 + trace_url 落库与按权限下发 (#35)
- user 加 debug_trace_enabled、comparison_record 加 trace_url
- 迁移 f8d3b1e60a27:加两列,down_revision 用 tuple 顺带合并既存双 head(invite_fingerprint_table + 044dce6e9b1f)
- UserOut 加 debug_trace_enabled → /me 与登录响应带出
- ComparisonRecordIn/Out 加 trace_url;upsert 落库
- /compare/records 列表与详情按 user.debug_trace_enabled 下发;详情连 raw_payload 里那份一并抹掉,防权限绕过
- admin 加 POST /users/{id}/debug-trace(operator + 审计),列表带该字段

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

Reviewed-on: #35
2026-06-10 15:35:08 +08:00
zhangxianze e69788eb41 feat(admin): 上报更低价人工审核(通过发1000金币/拒绝填理由) (#33)
运营后台审核用户上报的「某平台比我们算的最低价更便宜」+截图。

- routers/price_report.py:列表 + /summary、approve|reject(require operator)
- repositories:list_price_reports + price_report_summary(queries)、review_price_report(mutations)
- core/rewards.py:PRICE_REPORT_REWARD_COINS=1000(通过即发,grant_coins 由 router 同事务调)
- admin/main.py:注册 price_report_router

跨端配套:前端 admin-web feat/price-report-review;端上金币刷新 android feat/price-report-review。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: xianze <ze@192.168.0.128>
Reviewed-on: #33
Co-authored-by: zhangxianze <zhangxianze@wonderable.ai>
Co-committed-by: zhangxianze <zhangxianze@wonderable.ai>
2026-06-10 14:44:28 +08:00
marco cfc54ac2be fix(alembic): 合并 invite_fingerprint 与 044 双 head 修复 Multiple heads 部署失败
#31 的 invite_fingerprint_table 迁移 down_revision 挂在 0cf18d590b1d,与 #30
的 044dce6e9b1f(ad_reward+withdraw 合并) 形成并列双 head,alembic upgrade head
报 "Multiple head revisions are present",ecs1 部署在迁移步失败未重启。

加纯合并迁移 a8c47fc4dc39 (Revises: invite_fingerprint_table, 044dce6e9b1f)
收敛为单 head,upgrade/downgrade 为 no-op 不改 schema。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 02:28:56 +08:00
xiebing b2f5a53dd8 feat(invite): 被邀请人列表接口 + 指纹归因(任务3) (#31)
- GET /invitees: 分页 + 名字降级兜底(昵称→微信昵称→脱敏手机号)
- 指纹归因: 落地页采集 + (IP,设备型号)反查撞库 + 时间窗口闸
- test_invite: +5 个列表测试(脱敏/倒序/昵称优先/分页/空), 修指纹测试跨用例串味

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

Reviewed-on: #31
Co-authored-by: xiebing <xiebing@wonderable.ai>
Co-committed-by: xiebing <xiebing@wonderable.ai>
2026-06-09 21:48:48 +08:00
wuqi a828b51d9f feat(alembic): 添加合并广告奖励会话与提现安全的迁移脚本 (#30)
Reviewed-on: #30
Co-authored-by: wuqi <wuqi@wonderable.ai>
Co-committed-by: wuqi <wuqi@wonderable.ai>
2026-06-09 18:10:58 +08:00
marco 25b2b6850b feat(coupon): 领券弹窗频控加重置端点 + reset_today_engagement (#29)
- 加 POST /api/v1/coupon/prompt/reset:删该设备今日 engagement → has_engaged_today 变 false、
  今天又能弹。开发设置「重置今日领券弹窗状态」按钮调它(客户端改纯后台频控后前台清缓存已失效)
- coupon_state 加 reset_today_engagement(按 device_id + 今天删);should-show 文档改为"纯后台判据"

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

Reviewed-on: #29
2026-06-09 11:18:07 +08:00
50 changed files with 2017 additions and 67 deletions
@@ -0,0 +1,26 @@
"""merge invite_fingerprint and ad_reward heads
Revision ID: a8c47fc4dc39
Revises: invite_fingerprint_table, 044dce6e9b1f
Create Date: 2026-06-10 01:03:03.699443
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a8c47fc4dc39'
down_revision: Union[str, Sequence[str], None] = ('invite_fingerprint_table', '044dce6e9b1f')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -0,0 +1,58 @@
"""coupon daily completion table(今日跑完整轮领券 → 首页置灰源)
Revision ID: coupon_daily_completion
Revises: f8d3b1e60a27
Create Date: 2026-06-10 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "coupon_daily_completion"
down_revision: str | Sequence[str] | None = "f8d3b1e60a27"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"coupon_daily_completion",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("device_id", sa.String(length=64), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("complete_date", sa.Date(), nullable=False),
sa.Column("trace_id", sa.String(length=64), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("device_id", "complete_date", name="uq_coupon_completion_device_date"),
)
with op.batch_alter_table("coupon_daily_completion", schema=None) as batch_op:
batch_op.create_index(
batch_op.f("ix_coupon_daily_completion_user_id"), ["user_id"], unique=False
)
batch_op.create_index(
batch_op.f("ix_coupon_daily_completion_trace_id"), ["trace_id"], unique=False
)
def downgrade() -> None:
with op.batch_alter_table("coupon_daily_completion", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_coupon_daily_completion_trace_id"))
batch_op.drop_index(batch_op.f("ix_coupon_daily_completion_user_id"))
op.drop_table("coupon_daily_completion")
@@ -0,0 +1,49 @@
"""add user.debug_trace_enabled + comparison_record.trace_url
调试链接权限功能:
- user.debug_trace_enabled:运营后台给指定用户开「复制调试链接」权限
- comparison_record.trace_url:比价记录页「复制调试链接」的数据(pricebot done 帧给,
dir 名含落盘时分秒、前端/server 拼不出,必须落库)
注:本迁移最初基于旧 main#31,双 head)写、down_revision 曾指向那对父;但 #33 已用
a8c47fc4dc39 合并了那对双 head,故改为线性接在 a8c47fc4dc39 之后、只负责加两列
(避免与 a8c47fc4dc39 平行再造一个双 head)。
Revision ID: f8d3b1e60a27
Revises: a8c47fc4dc39
Create Date: 2026-06-10 02:40:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "f8d3b1e60a27"
down_revision: Union[str, Sequence[str], None] = "a8c47fc4dc39"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# sa.false() 渲染成 PG 的 false / SQLite 的 0,两端兼容(避免字符串 "false" 在 SQLite 上存歪)
op.add_column(
"user",
sa.Column(
"debug_trace_enabled",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
op.add_column(
"comparison_record",
sa.Column("trace_url", sa.String(length=512), nullable=True),
)
def downgrade() -> None:
op.drop_column("comparison_record", "trace_url")
op.drop_column("user", "debug_trace_enabled")
@@ -0,0 +1,50 @@
"""invite_fingerprint table (任务 3 指纹归因兜底)
Revision ID: invite_fingerprint_table
Revises: 0cf18d590b1d
Create Date: 2026-06-08 14:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'invite_fingerprint_table'
down_revision: Union[str, Sequence[str], None] = '0cf18d590b1d'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 邀请指纹归因表:落地页访问时记一行,登录后剪贴板没拿到邀请码时反查
op.create_table(
'invite_fingerprint',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('inviter_user_id', sa.Integer(), nullable=False),
sa.Column('ip', sa.String(length=64), nullable=False),
sa.Column('device_model', sa.String(length=64), nullable=False, server_default=''),
sa.Column('screen', sa.String(length=32), nullable=False, server_default=''),
sa.Column('user_agent', sa.Text(), nullable=False, server_default=''),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(['inviter_user_id'], ['user.id']),
sa.PrimaryKeyConstraint('id'),
)
# 反查主索引:(ip, screen, device_model, created_at)
# /bind 兜底分支 WHERE ip=? AND screen=? AND device_model=? AND created_at > now - 7d
# ORDER BY created_at DESC LIMIT 1 — SQLite/PG 优化器都能反向扫该索引
op.create_index(
'ix_invite_fp_match',
'invite_fingerprint',
['ip', 'screen', 'device_model', 'created_at'],
)
# 兜底/统计索引:按 inviter 看"这个邀请人的落地页被谁访问过"
op.create_index('ix_invite_fp_inviter', 'invite_fingerprint', ['inviter_user_id'])
def downgrade() -> None:
op.drop_index('ix_invite_fp_inviter', table_name='invite_fingerprint')
op.drop_index('ix_invite_fp_match', table_name='invite_fingerprint')
op.drop_table('invite_fingerprint')
@@ -0,0 +1,26 @@
"""merge ad feed reward session and withdraw safety heads
Revision ID: 044dce6e9b1f
Revises: ad_feed_reward_session, withdraw_safety_indexes
Create Date: 2026-06-09 09:52:08.276767
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '044dce6e9b1f'
down_revision: Union[str, Sequence[str], None] = ('ad_feed_reward_session', 'withdraw_safety_indexes')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
+4
View File
@@ -13,6 +13,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.admin.routers.ad_audit import router as ad_audit_router
from app.admin.routers.admins import router as admins_router
from app.admin.routers.audit import router as audit_router
from app.admin.routers.auth import router as auth_router
@@ -21,6 +22,7 @@ from app.admin.routers.dashboard import router as dashboard_router
from app.admin.routers.ops_stat_config import router as ops_stat_config_router
from app.admin.routers.feedback import router as feedback_router
from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router
from app.admin.routers.price_report import router as price_report_router
from app.admin.routers.users import router as users_router
from app.admin.routers.wallet import router as wallet_router
from app.admin.routers.withdraw import router as withdraw_router
@@ -80,7 +82,9 @@ admin_app.include_router(ops_marquee_seed_router)
admin_app.include_router(users_router)
admin_app.include_router(wallet_router)
admin_app.include_router(withdraw_router)
admin_app.include_router(price_report_router)
admin_app.include_router(feedback_router)
admin_app.include_router(admins_router)
admin_app.include_router(audit_router)
admin_app.include_router(config_router)
admin_app.include_router(ad_audit_router)
+168
View File
@@ -0,0 +1,168 @@
"""看广告金币审计:复算 expected_coin 并与实发对比。
只读。复用 [app.core.rewards] 的公式函数(不另写公式,避免与正式发奖口径漂移):
- 看视频:每条 granted = 1 份,第 N 份 = 当日该用户 granted 的 reward_video 顺序号
(与 ad_reward.grant_ad_reward 里 `_granted_today + 1` 一致)。
- 信息流:每条按 unit_count 份逐份累加,LT 序号 = 当日该用户已 granted 份数累计
(与 ad_feed_reward._unit_reward_total 的 existing_units 一致)。
非 granted(capped/ecpm_missing)不占用份序号、应发恒 0,据此校验闸口是否确实没发。
"""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core import rewards
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.models.ad_reward import AdRewardRecord
from app.repositories.ad_feed_reward import FEED_REWARD_UNIT_SECONDS
def _reward_video_rows(
db: Session, *, date: str, user_id: int | None
) -> list[dict]:
"""看视频记录复算。按 (user_id, created_at) 升序还原当日第 N 份。"""
stmt = (
select(AdRewardRecord)
.where(
AdRewardRecord.reward_date == date,
AdRewardRecord.reward_scene == "reward_video",
)
.order_by(AdRewardRecord.user_id, AdRewardRecord.created_at)
)
if user_id is not None:
stmt = stmt.where(AdRewardRecord.user_id == user_id)
granted_n: dict[int, int] = {} # user_id -> 已 granted 份数
rows: list[dict] = []
for rec in db.execute(stmt).scalars():
if rec.status == "granted":
nth = granted_n.get(rec.user_id, 0) + 1
granted_n[rec.user_id] = nth
expected = rewards.calculate_ad_reward_coin(rec.ecpm_raw, nth)
rows.append({
"scene": "reward_video",
"record_id": rec.id,
"user_id": rec.user_id,
"created_at": rec.created_at,
"status": rec.status,
"ecpm": rec.ecpm_raw,
"ecpm_factor": rewards.ad_ecpm_factor(rewards.parse_ecpm_yuan(rec.ecpm_raw)),
"units": 1,
"lt_index_start": nth,
"lt_index_end": nth,
"lt_factor_start": rewards.ad_lt_factor(nth),
"lt_factor_end": rewards.ad_lt_factor(nth),
"expected_coin": expected,
"actual_coin": rec.coin,
"matched": expected == rec.coin,
})
else:
# capped / ecpm_missing:不发金币,校验实发确为 0
rows.append({
"scene": "reward_video",
"record_id": rec.id,
"user_id": rec.user_id,
"created_at": rec.created_at,
"status": rec.status,
"ecpm": rec.ecpm_raw,
"ecpm_factor": None,
"units": 1,
"lt_index_start": None,
"lt_index_end": None,
"lt_factor_start": None,
"lt_factor_end": None,
"expected_coin": 0,
"actual_coin": rec.coin,
"matched": rec.coin == 0,
})
return rows
def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
"""信息流记录复算。granted 记录逐份累加,LT 序号沿用当日累计份数。"""
stmt = (
select(AdFeedRewardRecord)
.where(AdFeedRewardRecord.reward_date == date)
.order_by(AdFeedRewardRecord.user_id, AdFeedRewardRecord.created_at)
)
if user_id is not None:
stmt = stmt.where(AdFeedRewardRecord.user_id == user_id)
granted_units: dict[int, int] = {} # user_id -> 已 granted 份数累计
rows: list[dict] = []
for rec in db.execute(stmt).scalars():
if rec.status == "granted":
existing = granted_units.get(rec.user_id, 0)
units = rec.unit_count
expected = sum(
rewards.calculate_ad_reward_coin(rec.ecpm_raw, existing + offset)
for offset in range(1, units + 1)
)
granted_units[rec.user_id] = existing + units
start = existing + 1 if units > 0 else None
end = existing + units if units > 0 else None
rows.append({
"scene": "feed",
"record_id": rec.id,
"user_id": rec.user_id,
"created_at": rec.created_at,
"status": rec.status,
"ecpm": rec.ecpm_raw,
"ecpm_factor": rewards.ad_ecpm_factor(rewards.parse_ecpm_yuan(rec.ecpm_raw)),
"units": units,
"lt_index_start": start,
"lt_index_end": end,
"lt_factor_start": rewards.ad_lt_factor(start) if start else None,
"lt_factor_end": rewards.ad_lt_factor(end) if end else None,
"expected_coin": expected,
"actual_coin": rec.coin,
"matched": expected == rec.coin,
})
else:
rows.append({
"scene": "feed",
"record_id": rec.id,
"user_id": rec.user_id,
"created_at": rec.created_at,
"status": rec.status,
"ecpm": rec.ecpm_raw,
"ecpm_factor": None,
"units": rec.unit_count,
"lt_index_start": None,
"lt_index_end": None,
"lt_factor_start": None,
"lt_factor_end": None,
"expected_coin": 0,
"actual_coin": rec.coin,
"matched": rec.coin == 0,
})
return rows
def ad_coin_audit(
db: Session, *, date: str, user_id: int | None, scene: str | None, limit: int
) -> list[dict]:
"""返回当日发奖复算明细,按 created_at 倒序(最新在前)截断到 limit。
scene: None=两类都要 / "reward_video" / "feed"
份序号在截断前已基于全天数据算好,故 limit 只影响展示条数、不影响 expected 复算正确性。
"""
rows: list[dict] = []
if scene in (None, "reward_video"):
rows.extend(_reward_video_rows(db, date=date, user_id=user_id))
if scene in (None, "feed"):
rows.extend(_feed_rows(db, date=date, user_id=user_id))
rows.sort(key=lambda r: r["created_at"], reverse=True)
return rows[:limit]
def formula_snapshot() -> dict:
"""当前公式参数快照(给前端展示规则参照)。直接读 rewards 常量,与发奖同源。"""
return {
"coin_per_yuan": rewards.COIN_PER_YUAN,
"feed_unit_seconds": FEED_REWARD_UNIT_SECONDS,
"ecpm_factor_tiers": [list(t) for t in rewards.AD_ECPM_FACTOR_TABLE],
"lt_factor_tiers": [list(t) for t in rewards.AD_LT_FACTOR_TABLE],
}
+45
View File
@@ -8,9 +8,13 @@ set_user_status / update_feedback_status 支持 commit=False,让 router 把"业
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ
from app.models.feedback import Feedback
from app.models.price_report import PriceReport
from app.models.user import User
@@ -24,6 +28,20 @@ def set_user_status(db: Session, user: User, *, status: str, commit: bool = True
return user
def set_user_debug_trace(
db: Session, user: User, *, enabled: bool, commit: bool = True
) -> User:
"""开关用户「复制调试链接」权限。同 set_user_status:支持 commit=False 让 router 把
业务写 + 审计写放进同一事务。"""
user.debug_trace_enabled = enabled
if commit:
db.commit()
db.refresh(user)
else:
db.flush()
return user
def update_feedback_status(
db: Session, feedback: Feedback, *, status: str, commit: bool = True
) -> Feedback:
@@ -34,3 +52,30 @@ def update_feedback_status(
else:
db.flush()
return feedback
def review_price_report(
db: Session,
report: PriceReport,
*,
status: str,
reward_coins: int | None = None,
reject_reason: str | None = None,
commit: bool = True,
) -> PriceReport:
"""审核上报:置 approved/rejected + 记审核时间;通过填 reward_coins、拒绝填 reject_reason。
发金币(wallet.grant_coins)不在这里——由 router 在同一事务里调,涉钱逻辑不重写(见模块头注释)。
"""
report.status = status
if reward_coins is not None:
report.reward_coins = reward_coins
if reject_reason is not None:
report.reject_reason = reject_reason
report.reviewed_at = datetime.now(CN_TZ).replace(tzinfo=None) # 北京 wall-clock,同 created_at
if commit:
db.commit()
db.refresh(report)
else:
db.flush()
return report
+32
View File
@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session
from app.models.admin import AdminAuditLog
from app.models.comparison import ComparisonRecord
from app.models.feedback import Feedback
from app.models.price_report import PriceReport
from app.models.user import User
from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder
@@ -395,3 +396,34 @@ def get_user_overview(db: Session, user_id: int) -> dict | None:
).scalar_one(),
"feedback_total": _count(Feedback, Feedback.user_id == user_id),
}
def list_price_reports(
db: Session,
*,
status: str | None = None,
user_id: int | None = None,
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[PriceReport], int | None]:
"""上报更低价列表(admin 全量,可按状态/用户筛)。游标同 feedback:id 倒序。"""
stmt = select(PriceReport)
if status:
stmt = stmt.where(PriceReport.status == status)
if user_id is not None:
stmt = stmt.where(PriceReport.user_id == user_id)
return cursor_paginate(db, stmt, PriceReport.id, limit=limit, cursor=cursor)
def price_report_summary(db: Session) -> dict:
"""上报审核台各状态计数(待审核/已通过/已拒绝/合计)。"""
rows = db.execute(
select(PriceReport.status, func.count(PriceReport.id)).group_by(PriceReport.status)
).all()
by_status = {status: int(count) for status, count in rows}
return {
"pending": by_status.get("pending", 0),
"approved": by_status.get("approved", 0),
"rejected": by_status.get("rejected", 0),
"total": sum(by_status.values()),
}
+44
View File
@@ -0,0 +1,44 @@
"""admin 看广告金币审计:只读对账,核对发奖金币是否按公式计算。
任意已登录 admin 可看(只读,不涉及资金操作)。复算逻辑在 app/admin/repositories/ad_audit.py。
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from app.admin.deps import AdminDb, get_current_admin
from app.admin.repositories import ad_audit
from app.admin.schemas.ad_audit import AdCoinAuditOut, AdCoinAuditRow, AdCoinFormulaOut
from app.core.rewards import cn_today
router = APIRouter(
prefix="/admin/api/ad-coin-audit",
tags=["admin-ad-coin-audit"],
dependencies=[Depends(get_current_admin)],
)
@router.get("", response_model=AdCoinAuditOut, summary="看广告金币公式审计(复算对比)")
def get_ad_coin_audit(
db: AdminDb,
date: Annotated[str | None, Query(description="北京时间 YYYY-MM-DD,默认今天")] = None,
user_id: Annotated[int | None, Query(description="只看某用户;不传=全部用户")] = None,
scene: Annotated[
str | None, Query(description="reward_video / feed;不传=两类都要")
] = None,
limit: Annotated[int, Query(ge=1, le=500)] = 100,
) -> AdCoinAuditOut:
audit_date = date or cn_today().isoformat()
rows = ad_audit.ad_coin_audit(
db, date=audit_date, user_id=user_id, scene=scene, limit=limit,
)
items = [AdCoinAuditRow(**r) for r in rows]
return AdCoinAuditOut(
date=audit_date,
formula=AdCoinFormulaOut(**ad_audit.formula_snapshot()),
total=len(items),
mismatch_count=sum(1 for it in items if not it.matched),
items=items,
)
+103
View File
@@ -0,0 +1,103 @@
"""admin 上报更低价审核:列表 + 统计(读)+ 通过(发金币)/拒绝(写,带审计)。
数据由客户端 POST /api/v1/report 写入 price_report 表(提交即 pending);本路由是运营后台
对它的人工审核窗口。**通过** → 给上报用户钱包发固定金币(PRICE_REPORT_REWARD_COINS):
改状态 + 发金币(wallet.grant_coins)+ 审计同一事务一起 commit(原子,仿 users.grant_user_coins),
绝不只改状态不发钱或反之。客户端轮询 GET /api/v1/report/records 自动看到结果(无需推送)。
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
from app.admin.repositories import mutations, queries
from app.admin.schemas.common import CursorPage, OkResponse
from app.admin.schemas.price_report import (
PriceReportOut,
PriceReportRejectRequest,
PriceReportSummary,
)
from app.core.rewards import PRICE_REPORT_REWARD_COINS
from app.models.admin import AdminUser
from app.models.price_report import PriceReport
from app.repositories import wallet as wallet_repo
router = APIRouter(
prefix="/admin/api/price-reports",
tags=["admin-price-report"],
dependencies=[Depends(get_current_admin)],
)
@router.get("", response_model=CursorPage[PriceReportOut], summary="上报更低价列表(筛选+分页)")
def list_price_reports(
db: AdminDb,
status: Annotated[str | None, Query()] = None,
user_id: Annotated[int | None, Query()] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[PriceReportOut]:
items, next_cursor = queries.list_price_reports(
db, status=status, user_id=user_id, limit=limit, cursor=cursor,
)
return CursorPage(
items=[PriceReportOut.model_validate(r) for r in items], next_cursor=next_cursor,
)
@router.get("/summary", response_model=PriceReportSummary, summary="上报审核统计(各状态计数)")
def price_report_summary(db: AdminDb) -> PriceReportSummary:
return PriceReportSummary.model_validate(queries.price_report_summary(db))
@router.post("/{report_id}/approve", response_model=OkResponse, summary="通过上报(发固定金币)")
def approve_price_report(
report_id: int,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> OkResponse:
rep = db.get(PriceReport, report_id)
if rep is None:
raise HTTPException(status_code=404, detail="上报记录不存在")
if rep.status != "pending":
raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作")
coins = PRICE_REPORT_REWARD_COINS
# 改状态 + 发金币 + 审计同一事务(commit=False),最后一起 commit:改了就有痕、发了就留账
mutations.review_price_report(db, rep, status="approved", reward_coins=coins, commit=False)
wallet_repo.grant_coins(
db, rep.user_id, coins,
biz_type="price_report_reward", ref_id=str(rep.id), remark="上报更低价审核通过",
)
write_audit(
db, admin, action="price_report.approve", target_type="price_report", target_id=report_id,
detail={"reward_coins": coins, "user_id": rep.user_id}, ip=get_client_ip(request), commit=False,
)
db.commit()
return OkResponse()
@router.post("/{report_id}/reject", response_model=OkResponse, summary="拒绝上报")
def reject_price_report(
report_id: int,
body: PriceReportRejectRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> OkResponse:
rep = db.get(PriceReport, report_id)
if rep is None:
raise HTTPException(status_code=404, detail="上报记录不存在")
if rep.status != "pending":
raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作")
reason = body.reason.strip()
mutations.review_price_report(db, rep, status="rejected", reject_reason=reason, commit=False)
write_audit(
db, admin, action="price_report.reject", target_type="price_report", target_id=report_id,
detail={"reason": reason, "user_id": rep.user_id}, ip=get_client_ip(request), commit=False,
)
db.commit()
return OkResponse()
+23
View File
@@ -13,6 +13,7 @@ from app.admin.schemas.user import (
AdminUserListItem,
AdminUserOverview,
GrantCoinsRequest,
SetDebugTraceRequest,
SetUserStatusRequest,
)
from app.models.admin import AdminUser
@@ -77,6 +78,28 @@ def set_user_status(
return OkResponse()
@router.post("/{user_id}/debug-trace", response_model=OkResponse, summary="开关调试链接权限")
def set_user_debug_trace(
user_id: int,
body: SetDebugTraceRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> OkResponse:
user = user_repo.get_user_by_id(db, user_id)
if user is None:
raise HTTPException(status_code=404, detail="用户不存在")
before = user.debug_trace_enabled
# 业务写 + 审计写同一事务(commit=False),最后一起 commit(同 set_user_status)
mutations.set_user_debug_trace(db, user, enabled=body.enabled, commit=False)
write_audit(
db, admin, action="user.debug_trace.set", target_type="user", target_id=user_id,
detail={"before": before, "after": body.enabled}, ip=get_client_ip(request), commit=False,
)
db.commit()
return OkResponse()
@router.post("/{user_id}/coins", response_model=OkResponse, summary="手动增减金币(带审计)")
def grant_user_coins(
user_id: int,
+57
View File
@@ -0,0 +1,57 @@
"""看广告金币审计 schemas。
只读对账视图:把"看视频"(ad_reward_record)和"信息流"(ad_feed_reward_record)两类发奖记录,
用与正式发奖相同的公式 [app.core.rewards.calculate_ad_reward_coin] 复算一遍 expected_coin,
和实际入账的 actual_coin 对比,核对金币公式是否生效。字段 snake_case、金额按金币整数。
"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class AdCoinAuditRow(BaseModel):
"""单条发奖记录的复算明细。"""
scene: str = Field(..., description="reward_video(看视频) / feed(信息流)")
record_id: int = Field(..., description="对应记录表主键")
user_id: int
created_at: datetime
status: str = Field(..., description="granted / capped / ecpm_missing")
ecpm: str | None = Field(None, description="本次采用的 eCPM 原始值(分/千次展示,SDK getEcpm 原值)")
ecpm_factor: float | None = Field(None, description="因子1(eCPM 档)")
units: int = Field(..., description="折算份数:看视频恒为 1;信息流 = 满 10 秒的份数")
lt_index_start: int | None = Field(None, description="本条占用的当日第几份(起)")
lt_index_end: int | None = Field(None, description="本条占用的当日第几份(止);看视频 = 起")
lt_factor_start: float | None = Field(None, description="因子2(LT)起值")
lt_factor_end: float | None = Field(None, description="因子2(LT)止值;看视频 = 起值")
expected_coin: int = Field(..., description="按公式复算应发金币")
actual_coin: int = Field(..., description="实际入账金币")
matched: bool = Field(..., description="复算与实发是否一致(capped/ecpm_missing 校验是否确为 0)")
class AdCoinFormulaOut(BaseModel):
"""当前金币公式参数(给前端展示规则参照)。"""
description: str = Field(
"eCPM元 = getEcpm分 ÷ 100;单份金币 = round(eCPM元 ÷ 1000 × 因子1 × 因子2 × coin_per_yuan);"
"因子1 按 eCPM元 判档(阈值 100/200/400 元)",
description="公式说明",
)
coin_per_yuan: int = Field(..., description="金币:元 汇率")
ecpm_unit: str = Field("分/千次展示(SDK getEcpm 原值)", description="eCPM 口径")
feed_unit_seconds: int = Field(..., description="信息流每多少秒折 1 份")
# [(因子值, 区间下限, 区间上限或 null)]
ecpm_factor_tiers: list[tuple[float, int, int | None]] = Field(..., description="因子1 档位表")
lt_factor_tiers: list[tuple[float, int, int | None]] = Field(..., description="因子2 LT 档位表")
class AdCoinAuditOut(BaseModel):
"""审计响应:公式参照 + 命中条数 + 明细。"""
date: str = Field(..., description="审计日期(北京时间 YYYY-MM-DD)")
formula: AdCoinFormulaOut
total: int = Field(..., description="返回的明细条数")
mismatch_count: int = Field(..., description="其中 matched=false 的条数(=0 说明公式全部生效)")
items: list[AdCoinAuditRow]
+46
View File
@@ -0,0 +1,46 @@
"""admin 上报更低价审核 schemas。"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class PriceReportOut(BaseModel):
"""admin 列表项:price_report 全字段(审核要看的快照 + 截图 + 状态 + 奖励)。"""
model_config = ConfigDict(from_attributes=True)
id: int
user_id: int
comparison_record_id: int | None = None
# 选中比价记录的快照
store_name: str | None = None
dish_summary: str | None = None
original_platform_id: str | None = None
original_platform_name: str | None = None
original_price_cents: int | None = None
# 用户上报的更低价
reported_platform_id: str
reported_platform_name: str
reported_price_cents: int
images: list[str] = []
# 审核
status: str
reject_reason: str | None = None
reward_coins: int | None = None
reviewed_at: datetime | None = None
created_at: datetime
class PriceReportRejectRequest(BaseModel):
reason: str = Field(min_length=1, max_length=256, description="拒绝理由,用户端记录页会看到")
class PriceReportSummary(BaseModel):
"""审核台顶部各状态计数。"""
pending: int
approved: int
rejected: int
total: int
+5
View File
@@ -15,6 +15,7 @@ class AdminUserListItem(BaseModel):
nickname: str | None = None
register_channel: str
status: str
debug_trace_enabled: bool = False
wechat_openid: str | None = None
created_at: datetime
last_login_at: datetime
@@ -45,3 +46,7 @@ class SetUserStatusRequest(BaseModel):
status: Literal["active", "disabled"] = Field(
..., description="active=解封 / disabled=封禁(注销 deleted 不走此接口)"
)
class SetDebugTraceRequest(BaseModel):
enabled: bool = Field(..., description="是否给该用户开「复制调试链接」权限")
+10 -2
View File
@@ -304,10 +304,18 @@ def test_grant(user: CurrentUser, db: DbSession, payload: TestGrantIn | None = N
db.commit()
db.refresh(rec)
else:
# 优先用客户端按 ad_session_id 上报的真实 eCPM(走与正式发奖相同的公式);
# 取不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍能验出非零金币。
ad_session_id = payload.ad_session_id if payload is not None else None
ecpm_val = "200"
if ad_session_id:
ecpm_rec = crud_ecpm.find_by_session(db, user_id=user.id, ad_session_id=ad_session_id)
if ecpm_rec is not None and rewards.parse_ecpm_fen(ecpm_rec.ecpm_raw) > 0:
ecpm_val = ecpm_rec.ecpm_raw
try:
rec = crud_ad.grant_ad_reward(
db, user.id, trans_id, ecpm="200", reward_name="测试发奖",
raw="client debug test-grant ecpm=200",
db, user.id, trans_id, ecpm=ecpm_val, ad_session_id=ad_session_id,
reward_name="测试发奖", raw=f"client debug test-grant ecpm={ecpm_val}",
)
except crud_ad.UnknownUserError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e
+15 -5
View File
@@ -77,10 +77,12 @@ def list_records(
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,
)
outs = [ComparisonRecordOut.model_validate(it) for it in items]
# 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url(列表页「复制调试链接」靠它)
if not user.debug_trace_enabled:
for o in outs:
o.trace_url = None
return ComparisonRecordPage(items=outs, next_cursor=next_cursor)
@router.get(
@@ -94,4 +96,12 @@ def get_record(
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)
out = ComparisonRecordDetailOut.model_validate(rec)
# 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url。
# ⚠️ raw_payload 是上报体全量(model_dump),里面也藏着一份 trace_url,必须一并抹掉——
# 否则无权限用户从详情接口的 raw_payload 绕过权限闸拿到 trace_url。
if not user.debug_trace_enabled:
out.trace_url = None
if isinstance(out.raw_payload, dict):
out.raw_payload.pop("trace_url", None)
return out
+51 -2
View File
@@ -23,7 +23,11 @@ from app.core.config import settings
from app.core.pricebot_router import pick_pricebot
from app.db.session import SessionLocal
from app.repositories import coupon_state as coupon_repo
from app.schemas.coupon_state import CouponPromptDismissIn, CouponPromptShouldShowOut
from app.schemas.coupon_state import (
CouponCompletedTodayOut,
CouponPromptDismissIn,
CouponPromptShouldShowOut,
)
logger = logging.getLogger("shagua.coupon")
@@ -76,6 +80,14 @@ def _record_claims_blocking(
coupon_repo.record_claims(db, device_id, user_id, trace_id, results)
def _mark_completed_blocking(
device_id: str, user_id: int | None, trace_id: str | None
) -> None:
"""独立 session 写"今日已跑完整轮"(到 done 帧那刻调,best-effort 不阻塞领券)。"""
with SessionLocal() as db:
coupon_repo.mark_completed_today(db, device_id, user_id, trace_id)
@router.post("/step", summary="领券任务步进 (透传到 pricebot)")
async def coupon_step(
request: Request,
@@ -160,6 +172,19 @@ async def coupon_step(
except Exception as e: # noqa: BLE001
logger.warning("coupon claim write failed: %s", e)
# 整轮跑完(done 帧)→ 记一条"今日已完成",首页「去领取」卡据此置灰。
# pricebot 把中途单券 done 改写成 wait+continue=true,只有整套全跑完那帧才保留
# command=="done"(见 pricebot main.py),故 done 已等价"整轮完成"(用户决策 A 方案:
# 到 done 即算,不管单券成败),无需再判 continue。写库失败绝不连累领券返回。
action = resp_json.get("action") or {}
if device_id and action.get("command") == "done":
try:
await run_in_threadpool(
_mark_completed_blocking, device_id, user_id, trace_id
)
except Exception as e: # noqa: BLE001
logger.warning("coupon completion write failed: %s", e)
return resp_json
@@ -183,7 +208,31 @@ def coupon_prompt_should_show(
device_id: str, db: DbSession
) -> CouponPromptShouldShowOut:
"""今天这台设备已 engage(领或拒)过 → should_show=false。客户端据此决定弹不弹
(前台 SP 缓存做快速路径,这里是权威)"""
(纯后台判据,客户端不再做前台 SP 缓存判断)"""
return CouponPromptShouldShowOut(
should_show=not coupon_repo.has_engaged_today(db, device_id)
)
@router.post("/prompt/reset", summary="重置今日领券引导窗 engagement(开发测频控用)")
def coupon_prompt_reset(payload: CouponPromptDismissIn, db: DbSession) -> dict[str, bool]:
"""删这台设备今天的 engagement → has_engaged_today 变 false,今天又能弹。
开发设置重置今日领券弹窗状态按钮调MVP 不鉴权, device_id"""
coupon_repo.reset_today_engagement(db, payload.device_id)
return {"ok": True}
@router.get(
"/completed-today",
response_model=CouponCompletedTodayOut,
summary="这台设备今天是否已跑完整轮领券(到 done 帧)",
)
def coupon_completed_today(
device_id: str, db: DbSession
) -> CouponCompletedTodayOut:
"""今天这台设备已跑完整轮(到 done)→ completed=true。客户端据此把首页「去领取」
卡置灰不可点(用户决策 A 方案: done 即算,不管单券成败)判断维度 device_id
必须与领券循环上报的一致(客户端两端都用 ANDROID_ID)"""
return CouponCompletedTodayOut(
completed=coupon_repo.has_completed_today(db, device_id)
)
+172 -19
View File
@@ -1,25 +1,37 @@
"""好友邀请 endpoint。
路由前缀 /api/v1/invite, Bearer 鉴权(用户级数据):
GET /me 我的邀请码 + 分享链接 + 已邀人数/已得金币(邀请页展示)
POST /bind 把当前登录用户(被邀请人)绑定到某邀请码,注册即生效,双方各发金币
路由前缀 /api/v1/invite, Bearer 鉴权(用户级数据);唯一例外是 /landing-track
(落地页 dl.html 浏览器访问 token,详见任务 3 [[invite-three-tasks]]):
GET /me 我的邀请码 + 分享链接 + 已邀人数/已得金币(邀请页展示)
POST /bind 把当前登录用户(被邀请人)绑定到某邀请码;支持三种归因:
clipboard:首启读剪贴板拿邀请码 上报码
manual:用户在邀请页输入邀请码 上报码
fingerprint:剪贴板没拿到时上报指纹,后端反查
POST /landing-track 落地页 dl.html 访问时上报指纹(剪贴板兜底的服务端一端,无需鉴权)
客户端两种调用 /bind:
- 自动:首启读剪贴板拿到邀请码 登录后调本接口(channel=clipboard)
- 手动:用户在邀请页输入好友邀请码(channel=manual)
绑定的真正逻辑(邀请码生成/反查幂等自邀屏蔽发金币) repositories/invite.py
绑定的真正逻辑(邀请码生成/反查幂等自邀屏蔽发金币指纹反查)
repositories/invite.py
"""
from __future__ import annotations
import logging
import re
from fastapi import APIRouter
from fastapi import APIRouter, Request
from app.api.deps import CurrentUser, DbSession
from app.core import rewards
from app.core.config import settings
from app.repositories import invite as invite_repo
from app.schemas.invite import BindInviteIn, BindInviteOut, InviteInfoOut
from app.schemas.invite import (
BindInviteIn,
BindInviteOut,
InviteeItem,
InviteeListOut,
InviteInfoOut,
LandingTrackIn,
LandingTrackOut,
)
logger = logging.getLogger("shagua.invite")
@@ -31,9 +43,43 @@ _BIND_MESSAGES = {
"invalid_code": "邀请码无效",
"self_invite": "不能填写自己的邀请码",
"not_eligible": "邀请仅对新注册用户生效",
"fp_not_found": "未匹配到邀请关系",
}
def _client_ip(request: Request) -> str:
"""从 HTTP 头拿真实 IP。nginx 反代时走 X-Forwarded-For;裸跑 uvicorn 走 request.client。"""
xff = request.headers.get("x-forwarded-for", "")
if xff:
# X-Forwarded-For 可能是 "client, proxy1, proxy2" 链;取第一个 = 真实客户端
return xff.split(",")[0].strip()
return request.client.host if request.client else ""
# Android UA 形如 '... ; <Model> Build/<id>) AppleWebKit/...';抓 ';' 后到 ' Build/' 前
# 的 token = Build.MODEL(跨端跟客户端 android.os.Build.MODEL 对齐)。user-agents 库
# 把 Android 设备归为 'Smartphone' 通用名,抓不到真实型号,只能正则。
_ANDROID_BUILD_MODEL_RE = re.compile(r";\s*([^;]+?)\s+Build/", re.IGNORECASE)
def _parse_device_model(ua: str) -> str:
"""解析浏览器 UA 拿手机型号(如 '24115RA8EC''PJF110')。
Android:正则抓 UA ';...Build/' 前的 token(== Build.MODEL),跨端可严格匹配
iOS / 解析不到:退到 user-agents 库的通用解析,失败/UA 返空字符串
"""
if not ua:
return ""
m = _ANDROID_BUILD_MODEL_RE.search(ua)
if m:
return m.group(1).strip()
try:
from user_agents import parse
return (parse(ua).device.model or "").strip()
except Exception: # noqa: BLE001
return ""
@router.get("/me", response_model=InviteInfoOut, summary="我的邀请码 + 分享链接 + 战绩")
def my_invite(user: CurrentUser, db: DbSession) -> InviteInfoOut:
code = invite_repo.ensure_code(db, user)
@@ -48,17 +94,124 @@ def my_invite(user: CurrentUser, db: DbSession) -> InviteInfoOut:
)
@router.post("/bind", response_model=BindInviteOut, summary="绑定邀请人(注册即生效,双方发金币)")
def bind_invite(req: BindInviteIn, user: CurrentUser, db: DbSession) -> BindInviteOut:
result = invite_repo.bind(
db, invitee=user, invite_code=req.invite_code, channel=req.channel,
@router.get("/invitees", response_model=InviteeListOut, summary="我邀请的人列表(分页)")
def my_invitees(
user: CurrentUser, db: DbSession, limit: int = 20, offset: int = 0
) -> InviteeListOut:
"""邀请页小窗(取前几条) + 完整列表页(分页加载)共用。
名字/头像的降级兜底在 repositories/invite.get_invitees 算好,这里只组装响应
limit 夹到 [1,50] 防滥用;offset 不小于 0
"""
limit = max(1, min(limit, 50))
offset = max(0, offset)
items, total, has_more = invite_repo.get_invitees(
db, user.id, limit=limit, offset=offset,
)
return InviteeListOut(
items=[InviteeItem(**it) for it in items],
total=total,
has_more=has_more,
)
@router.post(
"/landing-track",
response_model=LandingTrackOut,
summary="落地页指纹采集(无需鉴权)",
)
def landing_track(
req: LandingTrackIn, request: Request, db: DbSession
) -> LandingTrackOut:
"""B 浏览器打开 dl.html?ref=xxx 时上报指纹。
服务端从 HTTP 头拿 IP/UA解析 UA device_model, req.screen 一起入库
无需鉴权(浏览器没 token)invalid_code / no_ip silent 返回(不抛 5xx 影响下载流程)
"""
inviter = invite_repo.resolve_inviter(db, req.ref)
if inviter is None or inviter.status != "active":
return LandingTrackOut(status="invalid_code")
ip = _client_ip(request)
if not ip:
return LandingTrackOut(status="no_ip")
ua_str = request.headers.get("user-agent", "")
device_model = _parse_device_model(ua_str)
invite_repo.record_fingerprint(
db,
inviter_user_id=inviter.id,
ip=ip,
screen=req.screen,
device_model=device_model,
user_agent=ua_str,
)
logger.info(
"invite bind invitee=%d code=%s channel=%s -> %s",
user.id, req.invite_code, req.channel, result.status,
"invite landing-track inviter=%d ip=%s screen=%s model=%s",
inviter.id, ip, req.screen, device_model,
)
return LandingTrackOut(status="ok")
@router.post("/bind", response_model=BindInviteOut, summary="绑定邀请人(注册即生效,双方发金币)")
def bind_invite(
req: BindInviteIn, user: CurrentUser, db: DbSession, request: Request
) -> BindInviteOut:
code = (req.invite_code or "").strip()
# 路径 1:有邀请码 → 走原路径(clipboard / manual)
if code:
result = invite_repo.bind(
db, invitee=user, invite_code=code, channel=req.channel,
)
logger.info(
"invite bind invitee=%d code=%s channel=%s -> %s",
user.id, code, req.channel, result.status,
)
return BindInviteOut(
status=result.status,
coins_awarded=result.invitee_coin,
message=_BIND_MESSAGES.get(result.status, result.status),
)
# 路径 2:无邀请码 + 有指纹 → 指纹兜底反查
if req.fingerprint is not None:
ip = _client_ip(request)
inviter = invite_repo.resolve_inviter_by_fingerprint(
db,
ip=ip,
screen=req.fingerprint.screen,
device_model=req.fingerprint.device_model,
window_days=rewards.INVITE_FP_WINDOW_DAYS,
)
if inviter is None:
logger.info(
"invite bind invitee=%d fingerprint not_found ip=%s screen=%s model=%s",
user.id, ip, req.fingerprint.screen, req.fingerprint.device_model,
)
return BindInviteOut(
status="fp_not_found",
coins_awarded=0,
message=_BIND_MESSAGES["fp_not_found"],
)
# 用反查到的 inviter.invite_code 走原 bind 流程(走原幂等/自邀/新人闸/发币逻辑)
result = invite_repo.bind(
db, invitee=user, invite_code=inviter.invite_code, channel="fingerprint",
)
logger.info(
"invite bind invitee=%d fingerprint -> inviter=%d code=%s -> %s",
user.id, inviter.id, inviter.invite_code, result.status,
)
return BindInviteOut(
status=result.status,
coins_awarded=result.invitee_coin,
message=_BIND_MESSAGES.get(result.status, result.status),
)
# 路径 3:啥都没传 → 无效请求
return BindInviteOut(
status=result.status,
coins_awarded=result.invitee_coin,
message=_BIND_MESSAGES.get(result.status, result.status),
status="invalid_code",
coins_awarded=0,
message=_BIND_MESSAGES["invalid_code"],
)
+28 -5
View File
@@ -77,6 +77,12 @@ def record_milestone_reward(milestone: int) -> int:
return RECORD_MILESTONES[milestone - 1]
# ===== 上报更低价(人工审核通过发固定金币)=====
# 用户上报"某平台比我们算的最低价更便宜"+ 截图,经运营后台人工审核通过后发放的固定金币奖励。
# 与广告/任务同量级;固定值(产品 2026-06 定),要调直接改这里;客户端记录页按 reward_coins 显示。
PRICE_REPORT_REWARD_COINS: int = 1000
# ===== 邀请好友(注册即生效,邀请人 + 被邀请人各发金币)=====
# 10000 金币 = 1 元,双方各得 1 元。MVP 先用固定常量(不走 app_config)。
INVITE_INVITER_COINS: int = 10000
@@ -85,9 +91,18 @@ INVITE_INVITEE_COINS: int = 10000
# 自动绑(剪贴板)在首次注册登录后几秒内发生;留 72h 给手动填码兜底。
INVITE_NEW_USER_WINDOW_HOURS: int = 72
# 指纹归因兜底(剪贴板被覆盖时):落地页 POST /landing-track 存的指纹记录,在此窗口内可被
# /bind 反查撞库。窗口取舍:太短(24h)覆盖不到"晚上看链接、第二天装"的常见场景;太宽
# (30d)IP/设备会漂、匹配错率上升。7 天兼顾"看广告→使用"周期与匹配精度。
INVITE_FP_WINDOW_DAYS: int = 7
# ===== 看激励视频 / 信息流广告发金币 =====
# 金币数值体系约定:eCPM 单位按"元/千次展示"处理,单次收入 = eCPM / 1000 元。
# eCPM 取自穿山甲 SDK getShowEcpm().getEcpm(),官方口径单位是【分/千次展示】(不是元!
# csjplatform 文档原文"通过 getEcpm 获取的单位是分")。计算时先 ÷100 转成元;
# 因子1 档位阈值按【元/千次】定(100/200/400 元 = ¥100/¥200/¥400 CPM,产品口径 2026-06-09)。
# 注:真实 eCPM 一般 <¥100 CPM,故多落最低档 0.1,高档基本不触发——这是产品有意的取舍。
# 单次展示收益(元) = eCPM元 ÷ 1000(每千次→单次)。
AD_ECPM_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = (
(0.1, 0, 100),
(0.3, 101, 200),
@@ -103,8 +118,8 @@ AD_LT_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = (
)
def parse_ecpm_yuan(ecpm: str | int | float | None) -> float:
"""解析 eCPM 原始值。当前产品口径:SDK 返回值按"元/千次展示"处理"""
def parse_ecpm_fen(ecpm: str | int | float | None) -> float:
"""解析 eCPM 原始值(穿山甲 getEcpm 原值,单位=分/千次展示)。非法/缺失→0"""
if ecpm is None:
return 0.0
try:
@@ -114,8 +129,14 @@ def parse_ecpm_yuan(ecpm: str | int | float | None) -> float:
return max(0.0, value)
def parse_ecpm_yuan(ecpm: str | int | float | None) -> float:
"""eCPM 转成元(getEcpm 原值是分,÷100)。因子档位判定与收益换算都用元。"""
return parse_ecpm_fen(ecpm) / 100.0
def ad_ecpm_factor(ecpm_yuan: float) -> float:
"""eCPM 档位因子:0-100=0.1,101-200=0.3,201-400=0.4,>400=0.6。"""
"""eCPM 档位因子(阈值单位=元/千次):≤100=0.1,101-200=0.3,201-400=0.4,>400=0.6。
产品口径(2026-06-09):阈值按元判档;真实 eCPM(<¥100 CPM)多落最低档 0.1"""
if ecpm_yuan > 400:
return 0.6
if ecpm_yuan > 200:
@@ -137,7 +158,9 @@ def ad_lt_factor(today_count_after_this: int) -> float:
def calculate_ad_reward_coin(ecpm: str | int | float | None, today_count_after_this: int) -> int:
"""按金币数值体系计算单份广告奖励金币。
单次奖励()=eCPM/1000 × 因子1(eCPM ) × 因子2(LT);再按 1 =10000 金币取整
eCPM 是穿山甲 getEcpm 原值,单位/千次展示; ÷100 转成元(因子判档 + 收益换算都用元)
单次收益()= eCPM元 ÷ 1000(每千次单次) × 因子1(eCPM 元档) × 因子2(LT);
再按 1 =10000 金币取整
"""
ecpm_yuan = parse_ecpm_yuan(ecpm)
yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(today_count_after_this)
+2
View File
@@ -9,10 +9,12 @@ from app.models.comparison import ComparisonRecord # noqa: F401
from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401
from app.models.coupon_state import ( # noqa: F401
CouponClaimRecord,
CouponDailyCompletion,
CouponPromptEngagement,
)
from app.models.feedback import Feedback # noqa: F401
from app.models.invite import InviteRelation # noqa: F401
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
from app.models.meituan_coupon import MeituanCoupon # noqa: F401
from app.models.ops_marquee_seed import OpsMarqueeSeed # noqa: F401
from app.models.ops_stat_config import OpsStatConfig # noqa: F401
+1 -1
View File
@@ -35,7 +35,7 @@ class AdEcpmRecord(Base):
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 原始字符串(单位:/千次展示,SDK getEcpm 原值,原样存)
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)
+4
View File
@@ -53,6 +53,10 @@ class ComparisonRecord(Base):
)
# pricebot 侧 trace_id:关联调试落盘 + 幂等去重键
trace_id: Mapped[str] = mapped_column(String(64), nullable=False)
# 本次比价的公网调试链接(price.shaguabijia.com/traces/{dir}/)。pricebot done 帧给、
# 客户端上报带上——dir 名含 pricebot 落盘的时分秒,前端/server 都拼不出,必须存。
# 查看接口按 user.debug_trace_enabled 决定返不返回。旧记录 / 未开上云为 None。
trace_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
# ===== 源平台(发起比价的那家)=====
source_platform_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
+43
View File
@@ -93,6 +93,49 @@ class CouponClaimRecord(Base):
)
class CouponDailyCompletion(Base):
"""按 (device, 自然日) 记"今天是否已跑完整轮领券(到 done 帧)"——首页置灰源。
engagement 区别:engagement = 用户表达过意向(点了一键领取/拒绝即记,不管跑没跑完);
completion = 这一轮真跑到了 done(整套流程走完)首页去领取卡据此置灰:跑完了
今天就不能再领判断口径(用户决策 2026-06-10 A 方案):** done 即算**,不管单券
成败(失败/跳过常是无障碍/环境问题,重复点也补不回来)判断维度 device_id,
engagement/claim 一致
"""
__tablename__ = "coupon_daily_completion"
__table_args__ = (
# 一台设备一天一条:今天跑完过就置灰。
UniqueConstraint(
"device_id", "complete_date",
name="uq_coupon_completion_device_date",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
# Asia/Shanghai 自然日。
complete_date: Mapped[date] = mapped_column(Date, nullable=False)
# 哪次任务跑到 done,回指 pricebot work_logs / 排查。
trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(),
nullable=False,
)
def __repr__(self) -> str: # pragma: no cover
return (
f"<CouponDailyCompletion device={self.device_id} "
f"date={self.complete_date}>"
)
class CouponPromptEngagement(Base):
"""按 (device, 自然日) 记"今天是否对领券引导窗表达过意向"——弹窗频控源。"""
+46
View File
@@ -0,0 +1,46 @@
"""邀请指纹归因表(剪贴板归因兜底)。
数据流(对应 [[invite-three-tasks]] 任务 3):
1. B 浏览器打开落地页 dl.html?ref=邀请码 JS POST /api/v1/invite/landing-track
2. 后端从 HTTP 头拿 IP/UA,解析 UA device_model, JS 上报的 screen 一起入库
3. B 装包首启 登录后,若剪贴板没拿到邀请码(被覆盖),客户端再算一次 screen+Build.MODEL
上报 后端用 (ip, screen, device_model) 反查本表 7 天内最近匹配 拿出 inviter_user_id
走原 bind 路径(channel=fingerprint)
不要索引(IP, created_at) 单独,组合索引 (ip, screen, device_model, created_at desc) 反查更省
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class InviteFingerprint(Base):
__tablename__ = "invite_fingerprint"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
inviter_user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False
)
# 落地页访问者 IP(服务端从 HTTP 头拿,X-Forwarded-For 由部署侧 nginx 透传)
ip: Mapped[str] = mapped_column(String(64), nullable=False)
# 客户端 Build.MODEL / 浏览器 UA 解析出来的型号(如 "PJF110"、"23046PNC9C")
device_model: Mapped[str] = mapped_column(String(64), nullable=False, default="")
# 屏幕分辨率 "1080x2400"(浏览器 screen.width × height,客户端 DisplayMetrics)
screen: Mapped[str] = mapped_column(String(32), nullable=False, default="")
# 完整 UA 字符串(留 debug / 排查"匹配不上"用,不直接参与匹配)
user_agent: Mapped[str] = mapped_column(Text, nullable=False, default="")
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"<InviteFingerprint inviter={self.inviter_user_id} "
f"ip={self.ip} model={self.device_model} screen={self.screen}>"
)
+8 -1
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy import Boolean, DateTime, Integer, String, false, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
@@ -51,6 +51,13 @@ class User(Base):
# 账号状态:active / disabled / deleted
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
# 调试链接权限:开了的用户在比价完成弹窗 + 比价记录页能看到「复制调试链接」按钮
# (复制 price.shaguabijia.com 的 trace 链接发给开发排障)。运营后台按用户配置;
# /me 与登录响应里带出给前端做条件渲染。默认 false。
debug_trace_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=false()
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
+1
View File
@@ -84,6 +84,7 @@ def upsert_record(
source_package=payload.source_package,
information=payload.information,
best_deeplink=payload.best_deeplink,
trace_url=payload.trace_url,
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],
+60 -2
View File
@@ -9,11 +9,15 @@ import logging
from datetime import date, datetime
from zoneinfo import ZoneInfo
from sqlalchemy import select
from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.models.coupon_state import CouponClaimRecord, CouponPromptEngagement
from app.models.coupon_state import (
CouponClaimRecord,
CouponDailyCompletion,
CouponPromptEngagement,
)
logger = logging.getLogger("shagua.coupon_state")
@@ -65,6 +69,60 @@ def mark_engagement(
db.rollback()
def reset_today_engagement(db: Session, device_id: str) -> int:
"""删这台设备今天的 engagement(开发设置「重置今日领券弹窗状态」调,测频控用)。
删后 has_engaged_today false,今天又能弹返回删除行数"""
result = db.execute(
delete(CouponPromptEngagement).where(
CouponPromptEngagement.device_id == device_id,
CouponPromptEngagement.engage_date == today_cn(),
)
)
db.commit()
return result.rowcount or 0
# ===== 今日跑完整轮(coupon_daily_completion)=====
def has_completed_today(db: Session, device_id: str) -> bool:
"""这台设备今天是否已跑完整轮领券(到 done 帧)。有 = 首页置灰、不能再领。"""
row = db.execute(
select(CouponDailyCompletion.id).where(
CouponDailyCompletion.device_id == device_id,
CouponDailyCompletion.complete_date == today_cn(),
)
).first()
return row is not None
def mark_completed_today(
db: Session, device_id: str, user_id: int | None, trace_id: str | None = None
) -> None:
"""记今日已跑完整轮。(device, 今天) 唯一,幂等 upsert。到 done 即记,不管单券成败。"""
today = today_cn()
row = db.execute(
select(CouponDailyCompletion).where(
CouponDailyCompletion.device_id == device_id,
CouponDailyCompletion.complete_date == today,
)
).scalar_one_or_none()
if row is not None:
if user_id is not None:
row.user_id = user_id
if trace_id is not None:
row.trace_id = trace_id
else:
db.add(CouponDailyCompletion(
device_id=device_id, user_id=user_id,
complete_date=today, trace_id=trace_id,
))
try:
db.commit()
except IntegrityError:
# 并发下另一请求刚插了同 (device, 日) → 唯一约束撞,回滚忽略(本就幂等)。
db.rollback()
# ===== 领券记录(coupon_claim_record)=====
def record_claims(
+119
View File
@@ -21,6 +21,7 @@ from sqlalchemy.orm import Session
from app.core import rewards
from app.models.invite import InviteRelation
from app.models.invite_fingerprint import InviteFingerprint
from app.models.user import User
from app.repositories import wallet as crud_wallet
@@ -164,3 +165,121 @@ def get_stats(db: Session, inviter_id: int) -> tuple[int, int]:
.where(InviteRelation.inviter_user_id == inviter_id)
).scalar_one()
return int(count), int(coins)
def _mask_phone(phone: str) -> str:
"""手机号脱敏:138****8888。前端拿不到完整号,展示被邀请人时在此兜底名字。
11 位标准手机号 3 + **** + 4;非标准(异常/截断) 只露后 4 ;太短 "新用户"
"""
p = (phone or "").strip()
if len(p) == 11:
return f"{p[:3]}****{p[-4:]}"
if len(p) >= 4:
return f"****{p[-4:]}"
return "新用户"
def get_invitees(
db: Session, inviter_id: int, *, limit: int = 20, offset: int = 0
) -> tuple[list[dict], int, bool]:
"""查某邀请人的被邀请人列表(按邀请时间倒序, 分页),返回 (items, total, has_more)。
每条 item = {display_name, avatar_url, coins, invited_at}降级兜底:
名字 = 昵称 微信昵称 脱敏手机号(很多被邀请人是刚注册新用户没设资料);
头像 = 用户头像 微信头像 None(前端画默认色块)
邀请关系表 join 用户表;total 单独 count(分页时算 has_more)
"""
total = db.execute(
select(func.count())
.select_from(InviteRelation)
.where(InviteRelation.inviter_user_id == inviter_id)
).scalar_one()
rows = db.execute(
select(InviteRelation, User)
.join(User, User.id == InviteRelation.invitee_user_id)
.where(InviteRelation.inviter_user_id == inviter_id)
.order_by(InviteRelation.created_at.desc())
.limit(limit)
.offset(offset)
).all()
items: list[dict] = []
for rel, u in rows:
items.append({
"display_name": u.nickname or u.wechat_nickname or _mask_phone(u.phone),
"avatar_url": u.avatar_url or u.wechat_avatar_url or None,
"coins": rel.inviter_coin,
"invited_at": rel.created_at,
})
has_more = offset + len(rows) < int(total)
return items, int(total), has_more
# ===== 指纹归因(剪贴板兜底,见 [[invite-three-tasks]] 任务 3)=====
def record_fingerprint(
db: Session,
*,
inviter_user_id: int,
ip: str,
screen: str,
device_model: str,
user_agent: str,
) -> InviteFingerprint:
"""落地页 dl.html 访问时调用,写一行指纹记录。
后续被邀请人登录剪贴板没拿到邀请码时,/bind 会按 (ip, screen, device_model) 反查本表
7 天内最近一条匹配 拿出 inviter_user_id 撞库
本函数会 commit;调用方(/landing-track endpoint)在此之前无其它写操作
"""
fp = InviteFingerprint(
inviter_user_id=inviter_user_id,
ip=ip[:64],
screen=screen[:32],
device_model=device_model[:64],
user_agent=user_agent[:2000] if user_agent else "", # 截一下防异常长 UA
)
db.add(fp)
db.commit()
db.refresh(fp)
return fp
def resolve_inviter_by_fingerprint(
db: Session,
*,
ip: str,
screen: str,
device_model: str,
window_days: int,
) -> User | None:
"""根据指纹反查邀请人(time window 内最近一条匹配)。
匹配规则:(ip, device_model) 相等 + created_at > now - window_days
screen 字段**只入库不反查**:浏览器算物理像素走 `CSS × devicePixelRatio` 路径
Android `DisplayMetrics.widthPixels` 真实硬件值,两端浮点 round 必然 ±1 像素漂移
(DPR 不严格是整数) 严格匹配注定撞不上 device_model 必同 screen(同型号同硬件)
screen 是冗余字段,删它不损精度撞错只有"同 IP 下同型号手机同时被邀请"才会
发生,中国家庭/公司 WiFi 场景概率极低
"""
if not ip:
return None
from datetime import datetime, timedelta, timezone
cutoff = datetime.now(timezone.utc) - timedelta(days=window_days)
fp = db.execute(
select(InviteFingerprint)
.where(
InviteFingerprint.ip == ip,
InviteFingerprint.device_model == device_model,
InviteFingerprint.created_at > cutoff,
)
.order_by(InviteFingerprint.created_at.desc())
.limit(1)
).scalar_one_or_none()
if fp is None:
return None
return db.get(User, fp.inviter_user_id)
+8 -3
View File
@@ -46,11 +46,11 @@ class AdRewardStatusOut(BaseModel):
class EcpmReportIn(BaseModel):
"""客户端上报一次广告展示的 eCPM(内部收益统计/对账)。
user_id 不在 body JWT (Bearer),防伪造ecpm 原样上报字符串,后端按/千次展示处理
user_id 不在 body JWT (Bearer),防伪造ecpm 原样上报字符串,后端按/千次展示处理(SDK getEcpm 原值,非元)
"""
ad_type: str = Field(..., description="广告类型:reward_video(激励视频) / draw(Draw 信息流) 等")
ecpm: str = Field(..., description="穿山甲 getShowEcpm().getEcpm() 原始字符串,按/千次展示处理")
ecpm: str = Field(..., description="穿山甲 getShowEcpm().getEcpm() 原始字符串,按/千次展示处理(SDK getEcpm 原值,非元)")
ad_session_id: str | None = Field(
None, min_length=8, max_length=64,
description="客户端生成的一次广告会话 id;激励视频 S2S extra 会透传同值",
@@ -89,6 +89,11 @@ class TestGrantIn(BaseModel):
"reward_video",
description="模拟发奖场景:reward_video(普通激励视频) / signin_boost(签到膨胀)",
)
ad_session_id: str | None = Field(
None, min_length=8, max_length=64,
description="本次广告会话 id(与 ecpm-report 同值)。reward_video 场景下据此查回客户端"
"已上报的真实 eCPM 来按公式发奖;查不到或 eCPM≤0 时兜底 200,保证本地联调仍出非零金币",
)
class TestGrantOut(BaseModel):
@@ -119,7 +124,7 @@ class FeedRewardIn(BaseModel):
ad_session_id: str | None = Field(
None, min_length=8, max_length=64, description="客户端生成的一次信息流广告会话 id"
)
ecpm: str = Field(..., description="本条信息流广告 eCPM,按/千次展示处理")
ecpm: str = Field(..., description="本条信息流广告 eCPM,按/千次展示处理(SDK getEcpm 原值,非元)")
duration_seconds: int = Field(..., ge=0, description="本条广告实际展示/播放秒数")
adn: str | None = Field(None, description="实际投放 ADN")
slot_id: str | None = Field(None, description="实际展示代码位")
+2
View File
@@ -25,6 +25,8 @@ class UserOut(BaseModel):
status: str
created_at: datetime
last_login_at: datetime
# 调试链接权限:前端据此在比价结果弹窗/记录页显示「复制调试链接」按钮。默认 false。
debug_trace_enabled: bool = False
# ===== Token 通用结构 =====
+5
View File
@@ -67,6 +67,9 @@ class ComparisonRecordIn(BaseModel):
status: str | None = Field(None, description="success / failed,可不传由服务端派生")
# 最优平台商家/商品深链(客户端从 collectedLinks[best_index] 取);「再次比价」直达用
best_deeplink: str | None = Field(None, description="最优平台深链,再次比价直达")
# pricebot done.params.trace_url 原样上报,落库供记录页「复制调试链接」(dir 名含落盘
# 时分秒前端拼不出,必须由后端透传)。
trace_url: str | None = Field(None, description="本次比价公网调试链接")
# ===== 读取出参 =====
@@ -79,6 +82,8 @@ class ComparisonRecordOut(BaseModel):
id: int
business_type: str
trace_id: str
# 公网调试链接;仅当 user.debug_trace_enabled 时由端点填充,否则端点层置 None(权限闸)。
trace_url: str | None = None
source_platform_id: str | None = None
source_platform_name: str | None = None
source_package: str | None = None
+6
View File
@@ -19,3 +19,9 @@ class CouponPromptShouldShowOut(BaseModel):
"""切到外卖 App 时是否还应弹领券引导窗。今天已 engage(领或拒)过 → false。"""
should_show: bool
class CouponCompletedTodayOut(BaseModel):
"""这台设备今天是否已跑完整轮领券(到 done 帧)。完成 → 首页「去领取」卡置灰。"""
completed: bool
+51 -3
View File
@@ -1,6 +1,8 @@
"""邀请相关 API 收发模型。"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
@@ -11,15 +13,61 @@ class InviteInfoOut(BaseModel):
coins_earned: int # 累计从邀请获得的金币
class LandingTrackIn(BaseModel):
"""落地页 dl.html 访问时上报的指纹(用于剪贴板归因失败时兜底)。"""
ref: str = Field(..., min_length=4, max_length=16, description="邀请码(落地页 ?ref=)")
screen: str = Field("", max_length=32, description="屏幕分辨率,如 1080x2400")
# IP / UA 服务端从 HTTP 头自动拿,无需 JS 上报
class LandingTrackOut(BaseModel):
status: str # ok / invalid_code / no_ip
class FingerprintIn(BaseModel):
"""客户端登录后剪贴板归因失败时上报的设备指纹。
跨端匹配字段:浏览器落地页存的同名字段 == 客户端 Build.MODEL / DisplayMetrics 算的值
IP 服务端从 HTTP 头拿,不在这里
"""
screen: str = Field("", max_length=32)
device_model: str = Field("", max_length=64)
class BindInviteIn(BaseModel):
invite_code: str = Field(..., min_length=4, max_length=16, description="邀请人的邀请码")
# invite_code: 可选 — 走指纹兜底时为空(channel=fingerprint)
invite_code: str | None = Field(
None, max_length=16, description="邀请码;走指纹兜底时为空"
)
channel: str = Field(
"clipboard", max_length=16,
description="归因来源:clipboard(首启读剪贴板自动) / manual(用户手动填)",
description="归因来源:clipboard(首启读剪贴板自动) / manual(手动填) / fingerprint(指纹兜底)",
)
# 当 invite_code 为空、channel=fingerprint 时,后端用这组指纹反查
fingerprint: FingerprintIn | None = None
class BindInviteOut(BaseModel):
status: str # success / already_bound / invalid_code / self_invite / not_eligible
status: str # success / already_bound / invalid_code / self_invite / not_eligible / fp_not_found
coins_awarded: int # 本次给当前用户(被邀请人)发的金币
message: str # 给前端直接展示的文案
class InviteeItem(BaseModel):
"""被邀请人列表的一条(邀请页小窗 / 完整列表页共用)。
display_name / avatar_url "降级兜底"在后端算好( repositories/invite.get_invitees):
名字 = 昵称 微信昵称 脱敏手机号(前端拿不到完整号,必须后端脱敏);
头像 = 用户头像 微信头像 null(前端拿到 null 画默认色块)
"""
display_name: str # 已兜底好的显示名(真名/脱敏号)
avatar_url: str | None = None # 头像 URL;null = 前端画默认色块
coins: int # 这次邀请给我(邀请人)发的金币
invited_at: datetime # 邀请绑定时间(前端转"今天/3天前")
class InviteeListOut(BaseModel):
"""GET /invite/invitees 响应:被邀请人列表 + 分页。"""
items: list[InviteeItem]
total: int # 我邀请的总人数(用于"共 N 人")
has_more: bool # 还有下一页吗(完整列表页滚到底加载更多)
+48 -15
View File
@@ -62,11 +62,11 @@
<div class="feat">💰 <span>省下的钱看得见,还能<b>赚金币提现</b></span></div>
</div>
<button class="btn" id="dlbtn">⬇️ 下载安装包</button>
<div class="hint" id="hint">Android 安卓版 · 安装包约 24 MB</div>
<button class="btn" id="dlbtn">🏪 打开应用商店下载</button>
<div class="hint" id="hint">Android 安卓版 · 应用商店安全下载</div>
<div class="foot">
安装时如提示「未知来源」,请允许后继续安装<br>
将前往应用商店下载,安全放心<br>
本页为内部测试页。
</div>
@@ -82,22 +82,45 @@
<div class="wxsteps">
<div><span class="n">1</span>点右上角的 ··· 菜单</div>
<div><span class="n">2</span>选择「在浏览器打开」</div>
<div><span class="n">3</span>在浏览器里点「下载安装包」</div>
<div><span class="n">3</span>在浏览器里按提示去应用商店下载</div>
</div>
</div>
<div class="closebar" id="wxclose">我知道了 ✕</div>
</div>
<script>
// APK 下载地址跟随当前页面 origin:本地测试时页面由笔记本 app-server(LAN:8770)托管 →
// 下载也走 LAN;生产由 app-api.shaguabijia.com 托管 → 下载走生产域名。无需按机器改 IP
var APK_URL = location.origin + "/media/shaguabijia.apk";
// 应用商店跳转:用包名(App 唯一标识 = 身份证号)定位到傻瓜比价的商店下载页。
// 主:market:// 唤起手机自带应用市场(华为/小米/OV);兜底:应用宝网页(任何浏览器都能开)
var PKG = "com.jishisongfu.shaguabijia";
var MARKET_URL = "market://details?id=" + PKG;
var YYB_URL = "https://a.app.qq.com/o/simple.jsp?pkgname=" + PKG;
var ua = navigator.userAgent || "";
var isWeChat = /MicroMessenger/i.test(ua);
var isIOS = /iPhone|iPad|iPod/i.test(ua);
var isAndroid = /Android/i.test(ua);
var ref = new URLSearchParams(location.search).get("ref"); // 邀请码(来自二维码 URL ?ref=)
// 【任务 3】指纹归因兜底:页面加载即上报访问者指纹,后端存 invite_fingerprint 表。
// 当 APK 首启读剪贴板失败(被覆盖)时,客户端会用 (IP+屏幕+UA 解析的手机型号) 反查
// 本表 7 天内最近一条匹配 → 撞库出原邀请人 → 走原 bind 流程。
// 任何失败都 silent(不影响下载主流程);后端 invalid_code/no_ip 也只返 200。
if (ref) {
// screen 报【物理像素】= CSS 像素 × devicePixelRatio,跟 Android dm.widthPixels(物理像素)对齐。
// 不同设备 DPR 不同(常见 2/2.5/3/3.5),CSS 像素直接报会跟客户端不对齐 → 撞不上库。
var _dpr = window.devicePixelRatio || 1;
var _sw = Math.round(screen.width * _dpr);
var _sh = Math.round(screen.height * _dpr);
fetch("/api/v1/invite/landing-track", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
ref: ref,
screen: _sw + "x" + _sh,
// IP / UA 服务端从 HTTP 头自动拿,无需 JS 上报
}),
}).catch(function () {}); // silent,绝不阻断下载
}
// 把邀请码写进剪贴板,APK 首启时读出来完成归因(deferred deeplink 的关键一步)。
// 浏览器要求:必须在用户点击手势里调用 + HTTPS 下才允许写。
function legacyCopy(payload) { // 老 webview / 无 clipboard API 兜底
@@ -117,12 +140,20 @@
}
return Promise.resolve();
}
// 确保剪贴板写完(或最多等 400ms 兜底,防写入卡住)再触发下载。
function startDownload() {
var fired = false;
function go() { if (!fired) { fired = true; window.location.href = APK_URL; } }
copyInviteCode().then(go);
setTimeout(go, 400);
// 跳应用商店:先尝试 market:// 唤起手机自带商店;若 2.5s 内页面没切到后台
//(= 没有商店接管 market://),兜底跳应用宝网页下载页,不让用户卡死。
function openStore() {
var jumped = false;
function markJumped() { jumped = true; } // 页面切到后台 = 商店已唤起
document.addEventListener("visibilitychange", function () {
if (document.hidden) jumped = true;
});
window.addEventListener("pagehide", markJumped);
window.addEventListener("blur", markJumped);
window.location.href = MARKET_URL; // 唤起自带应用市场
setTimeout(function () {
if (!jumped) window.location.href = YYB_URL; // 没唤起 → 应用宝网页兜底
}, 2500);
}
var hint = document.getElementById("hint");
@@ -139,8 +170,10 @@
document.getElementById("dlbtn").addEventListener("click", function(){
if (isWeChat) { showMask(); return; } // 微信内:引导去浏览器
if (isIOS) { alert("iOS 版即将上线,请前往 App Store 搜索「傻瓜比价」"); return; }
// 普通浏览器:先把邀请码写进剪贴板(写完或 400ms 兜底),再下载 APK(首启读回完成归因)
startDownload();
// 安卓浏览器:先把邀请码写进剪贴板(legacyCopy 同步写、最可靠),再跳应用商店。
// 剪贴板供 App 首启归因;链路长易丢时由指纹兜底(已上报 landing-track)接住。
copyInviteCode();
openStore();
});
</script>
</body>
+90
View File
@@ -0,0 +1,90 @@
# 美团券 ETL 定时任务 — 运维手册
> 对象:维护「美团 CPS 券抓取入库」这套定时任务的同事。
> 🔒 登录信息(服务器 IP / 账号密码 / DB 密码)见**私密交接清单**,**不入库**。
> 运行账号 `cps` **无 sudo**,本手册所有操作都不需要 root。
## 它是什么
每小时全量抓美团 CPS 券(北京)→ upsert 进 PostgreSQL 的 `meituan_coupon` 表,作为 App「销量最高 / 智能推荐」两个 tab 的数据源。一轮约 140 秒、~2700 条;靠 `cps` 用户的 crontab 常驻,关终端也照跑。
## 代码 / 文件在哪(都在生产服务器上)
| 项 | 路径 |
|---|---|
| 代码目录 | `/opt/shaguabijia-app-server` |
| 抓取脚本 | `scripts/pull_meituan_coupons.py` |
| Python(venv) | `/opt/shaguabijia-app-server/.venv/bin/python` |
| 配置(美团凭证 / DB 连接串) | `/opt/shaguabijia-app-server/.env`(`MT_CPS_*` / `DATABASE_URL`)|
| 日志 | `/home/cps/meituan_etl.log` |
| 运行锁 | `/tmp/meituan_etl.lock`(根治版)或 `~/data/.meituan_etl.lock`(过渡版,见文末)|
| 定时配置 | `cps` 用户 crontab(`crontab -l`)|
| 入库表 | PostgreSQL `shaguabijia` 库的 `meituan_coupon` |
## 怎么看在不在跑 / 健康
```bash
crontab -l # 看定时任务(应有每小时第 17 分那条)
tail -n 20 ~/meituan_etl.log # 看最近几轮:本轮完成 / 入库条数 / 有无报错
# 查库存量与新鲜度(走 .env 连接串,不用输 DB 密码):
cd /opt/shaguabijia-app-server && .venv/bin/python -c "from sqlalchemy import text; from app.db.session import engine; print(engine.connect().execute(text('select count(*) total, count(*) filter (where sale_volume_num is not null) sales, max(last_seen) last_run from meituan_coupon')).one())"
```
**健康标准**:`last_run` 在最近 12 小时内、`total` 两三千、日志最近一轮是 `本轮完成`(不是 `Permission denied` / Traceback)。
## 怎么启动
定时任务已在 `cps` 的 crontab 里常驻,**随系统 cron 服务自动运行,无需手动启动**。若 crontab 被清空、要重新装回:`crontab -e`,粘下面这行存盘。
```cron
# 根治版(锁文件已改到 /tmp 后用这条,推荐):
17 * * * * cd /opt/shaguabijia-app-server && .venv/bin/python -m scripts.pull_meituan_coupons --once --prune-hours 24 >> /home/cps/meituan_etl.log 2>&1
```
**手动立即跑一轮**(不等下一个整点,比如刚发现库空):
```bash
cd /opt/shaguabijia-app-server && .venv/bin/python -m scripts.pull_meituan_coupons --once --prune-hours 24
```
前台跑、约 140 秒,出现 `本轮完成` 即成功。
## 怎么关闭(停用定时任务)
```bash
crontab -e
```
在那一行最前面加 `#` 注释掉(**推荐**,保留备查),存盘即停;要彻底删就删那一行。
> ⚠️ 别用 `crontab -r`——它会**清空你所有 cron**,不只这一条。
## 怎么终止(杀掉正在跑的那一轮)
```bash
pgrep -af pull_meituan_coupons # 查在跑的进程和 PID
pkill -f pull_meituan_coupons # 杀掉它(或 kill <PID>)
rm -f /tmp/meituan_etl.lock ~/data/.meituan_etl.lock # 清掉残留锁,否则下一轮可能"跳过本轮"
```
> 脚本有 **30 分钟陈旧锁自动接管**机制:即使没手动清锁,30 分钟后下一轮也会自愈;急着立刻重跑才需手动 `rm` 锁。
## 调整频率
`crontab -e` 改那行最前面的 cron 表达式:
| 频率 | 表达式 |
|---|---|
| 每小时第 17 分(当前) | `17 * * * *` |
| 每 30 分钟 | `*/30 * * * *` |
| 每 2 小时 | `17 */2 * * *` |
> ⚠️ 别调太密:一轮 ~140 秒,且要守美团限流(脚本已配速到 ~2–3 req/s,远低于 ~15 req/s 红线;402 内置退避重试)。**每小时一轮足够**,勿并行、勿调小页间 sleep。
## 脚本参数
- `--once`:跑一轮(定时任务用这个)
- `--loop --interval 600`:常驻循环,每 600 秒一轮(调试用)
- `--prune-hours N`:清理超过 N 小时没再出现的旧券(默认 24;`0`=不清)
- 锁文件位置可用环境变量 `MEITUAN_ETL_LOCK=/path/to.lock` 覆盖
> 详见脚本头部注释:`head -30 scripts/pull_meituan_coupons.py`
## 已知问题与根治(运行锁权限)
- **现象**:cron 报 `PermissionError: [Errno 13] ... 'data/.meituan_etl.lock'`,每轮抓不进数据、表为空。
- **根因**:旧版脚本把运行锁建在代码目录的 `data/` 下,而 `data/` 属 root、`cps` 不可写(交接清单里"已给写权限"实际不成立)。
- **根治(已在代码层修复)**:脚本把锁默认改到 `/tmp/meituan_etl.lock`(任何账号可写),并支持 `MEITUAN_ETL_LOCK` 覆盖。**部署该版本后,用上面「根治版」那条干净 crontab 即可。**
- **过渡写法(根治版部署前的临时方案)**:crontab 里 `cd /home/cps` 把锁落到家目录、再用 `PYTHONPATH` 让代码仍能 import:
```cron
17 * * * * cd /home/cps && PYTHONPATH=/opt/shaguabijia-app-server /opt/shaguabijia-app-server/.venv/bin/python -m scripts.pull_meituan_coupons --once --prune-hours 24 >> /home/cps/meituan_etl.log 2>&1
```
根治版上线后,把这条换回上面的「根治版」标准写法即可。
## 注意事项
- **只抓北京**:脚本里 `city_id` 硬编码,扩城市需改脚本。
- **表行数 > 单轮抓取数**属正常:美团 `product_view_sign` 会轮换,跨轮累积;查询侧有 `DISTINCT ON(dedup_key)` 按「品牌\|名\|价」去重,用户看到的不重复;旧券 24h 后被 prune 清掉。
- **别和 systemd 双跑**:仓库 `deploy/meituan-etl.{service,timer}` 是**未来由 root 纳入 systemd 正规部署**的备选,和本 user cron **二选一、别同时开**(否则重复抓、浪费限流额度)。当前生产用 user cron。
- **改脚本 / 改部署**:走 git + PR,让有 root 的人 `/opt/deploy.sh` 部署(`git reset` + `alembic upgrade` + 重启);`cps` 账号改不了 `/opt` 下的代码。
+33
View File
@@ -0,0 +1,33 @@
# 美团 CPS 券 ETL —— 单轮抓取入库,由 meituan-etl.timer 每小时触发(Linux 服务器用)。
#
# 仅用于 mentor 的 Linux 服务器;本机 Windows 开发无 systemd,直接手动跑脚本即可:
# .venv\Scripts\python -m scripts.pull_meituan_coupons --once --prune-hours 24
#
# 部署(服务器):
# sudo cp deploy/meituan-etl.{service,timer} /etc/systemd/system/
# sudo systemctl daemon-reload && sudo systemctl enable --now meituan-etl.timer
#
# 前置:DATABASE_URL 必须是 postgresql+psycopg://(ETL 的 upsert 是 PG 专用);
# MT_CPS_APP_KEY/SECRET 已配;服务器上 MT_CPS_PROXY 留空(直连)。
[Unit]
Description=Meituan CPS coupon ETL (one-shot, driven by timer)
After=network-online.target postgresql.service
Wants=network-online.target postgresql.service
[Service]
Type=oneshot
User=root
WorkingDirectory=/opt/shaguabijia-app-server
Environment="PATH=/opt/shaguabijia-app-server/.venv/bin:/usr/bin:/bin"
EnvironmentFile=/opt/shaguabijia-app-server/.env
ExecStart=/opt/shaguabijia-app-server/.venv/bin/python -m scripts.pull_meituan_coupons --once --prune-hours 24
SyslogIdentifier=meituan-etl
# 脚本自带 30min 文件锁;给 20min 硬超时,防卡死轮次长期占锁。
TimeoutStartSec=1200
# 与主服务 shaguabijia-app-server.service 同款加固。
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/opt/shaguabijia-app-server
ProtectHome=true
+14
View File
@@ -0,0 +1,14 @@
# 每小时触发一次美团 CPS 券 ETL(Linux 服务器用)。
# 见 meituan-etl.service 顶部注释的部署步骤。
[Unit]
Description=Run Meituan CPS coupon ETL hourly
[Timer]
# 每小时第 5 分钟跑,避开整点其它定时任务扎堆。
OnCalendar=*-*-* *:05:00
# 服务器宕机/重启后,补跑错过的那一轮(而不是干等下一个整点)。
Persistent=true
AccuracySec=1min
[Install]
WantedBy=timers.target
+1
View File
@@ -102,6 +102,7 @@
| A24 | `DELETE /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) |
| A25 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin-marquee-seeds.md) |
| A26 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./admin-marquee-seeds.md) |
| A27 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin-ad-coin-audit.md)(看广告金币公式复算对账,只读) |
| - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) |
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link``sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
+1 -1
View File
@@ -9,7 +9,7 @@
|---|---|---|---|
| `ad_type` | str | 是 | 广告类型:`reward_video`(激励视频) / `draw`(Draw 信息流) 等 |
| `ad_session_id` | str\|null | 否 | 客户端生成的广告会话 ID;需和穿山甲 `extra.ad_session_id` 一致,用于 S2S 缺 eCPM 时匹配 |
| `ecpm` | str | 是 | 穿山甲 `getShowEcpm().getEcpm()` 原始字符串,后端按“元/千次展示”参与金币公式 |
| `ecpm` | str | 是 | 穿山甲 `getShowEcpm().getEcpm()` 原始字符串,单位是**分/千次展示**(非元),后端 ÷100 转元参与金币公式 |
| `adn` | str\|null | 否 | 实际投放 ADN(`getSdkName`),如 `pangle` |
| `slot_id` | str\|null | 否 | 实际展示代码位(底层 mediation rit,非客户端配置位) |
+1 -1
View File
@@ -12,7 +12,7 @@
|---|---|---:|---|
| `client_event_id` | string | 是 | 客户端生成的幂等事件 id,8-64 字符 |
| `ad_session_id` | string\|null | 否 | 客户端生成的一次信息流广告会话 id,用于对账/排查 |
| `ecpm` | string | 是 | 本条信息流广告 eCPM,按“/千次展示”处理 |
| `ecpm` | string | 是 | 本条信息流广告 eCPM(穿山甲 getEcpm 原值),按“/千次展示”处理(非元) |
| `duration_seconds` | int | 是 | 实际展示/播放秒数 |
| `adn` | string\|null | 否 | 实际投放 ADN |
| `slot_id` | string\|null | 否 | 实际展示代码位 |
+3
View File
@@ -10,6 +10,7 @@
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `reward_scene` | string | 否 | `reward_video` | 模拟发奖场景。`reward_video`=普通激励视频;`signin_boost`=签到膨胀 |
| `ad_session_id` | string(8~64) \| null | 否 | null | 本次广告会话 id(与 [ecpm-report](./ad-ecpm-report.md) 同值)。**仅 `reward_video` 场景生效**:据此查回客户端已上报的真实 eCPM,走与正式发奖相同的公式发奖;查不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍出非零金币 |
## 出参
响应 `200`:`TestGrantOut`
@@ -30,4 +31,6 @@
## 说明
没公网、穿山甲 S2S 回调打不到本地时,debug 客户端看完广告后调它,直接走与 [ad-pangle-callback](./ad-pangle-callback.md) 相同的发奖逻辑(每次新 `trans_id`,幂等 + 每日上限/今日膨胀一次)。
`reward_scene=reward_video` 时按上面 `ad_session_id` 查回的真实 eCPM 走金币公式发奖(取不到兜底 200)——便于本地用 [admin 金币审计](./admin-ad-coin-audit.md) 核对「看广告→金币」是否按公式计算。
`reward_scene=signin_boost` 时复用签到膨胀业务规则:必须当天已签到、非第 14 天、当天未膨胀过,成功后写入 `signin_boost` 金币流水。它让已登录客户端能自助发奖 = 绕过反作弊,**严禁在生产开启**。
+69
View File
@@ -0,0 +1,69 @@
# Admin 看广告金币审计
> 所属:Admin 组(前缀 `/admin/api/ad-coin-audit`) | 鉴权:Admin Bearer(任意已登录 admin,只读) | [← 返回 API 索引](./README.md)
把「看视频赚金币」(`ad_reward_record`)和「比价信息流广告」(`ad_feed_reward_record`)两类发奖记录,用与**正式发奖完全相同**的公式 [`app/core/rewards.py` `calculate_ad_reward_coin`](../../app/core/rewards.py) 复算一遍 `expected_coin`,与实际入账的 `actual_coin` 对比,核对金币公式是否生效。**纯只读对账**,不发币、不改任何数据。
相关表:[ad_reward_record](../database/ad_reward_record.md)、[ad_feed_reward_record](../database/ad_feed_reward_record.md)。
## 金币公式(展示参照)
```
eCPM元 = getEcpm分 ÷ 100
单份金币 = round( eCPM元 ÷ 1000 × 因子1 × 因子2 × coin_per_yuan ) # coin_per_yuan=10000
```
- **eCPM 口径**:`getEcpm()` 原值单位是分/千次展示;先 ÷100 转元,**因子判档与收益换算都用元**。
- **因子1(eCPM 档,阈值单位=元/千次)**:≤100→0.1,101200→0.3,201400→0.4,>400→0.6(即 ¥100/¥200/¥400 CPM 分界。**真实 eCPM 多 <¥100 CPM,故常落最低档 0.1,高档基本不触发——产品有意取舍**)。
- **因子2(LT,当日第 N 份)**:第1→2.0,第2→1.5,第3→1.3,第410→1.1,≥11→1.0。
- **第 N 份的计数**:看视频每条 granted 记 1 份;信息流每满 10 秒记 1 份。**两个点位各自独立计数**(看视频的份数不影响信息流的份数,反之亦然)。
## GET /admin/api/ad-coin-audit — 复算对比
- 入参(均 query,可选):
| 参数 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `date` | string | 今天 | 北京时间 `YYYY-MM-DD`,审计某天 |
| `user_id` | int | 全部 | 只看某用户;不传=所有用户 |
| `scene` | string | 两类 | `reward_video` / `feed`;不传=两类都返回 |
| `limit` | int(1~500) | 100 | 返回明细条数(按时间倒序截断;**份序号在截断前已按全天数据算好**,不影响复算正确性) |
- 出参 `200`:`AdCoinAuditOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `date` | string | 审计日期 |
| `formula` | object | 当前公式参数快照(见下) |
| `total` | int | 返回明细条数 |
| `mismatch_count` | int | 其中 `matched=false` 的条数;**=0 说明全部按公式发放** |
| `items` | `AdCoinAuditRow[]` | 明细(见下) |
### AdCoinFormulaOut(`formula`)
| 字段 | 类型 | 说明 |
|---|---|---|
| `description` | string | 公式文字说明 |
| `coin_per_yuan` | int | 金币:元 汇率(10000) |
| `ecpm_unit` | string | eCPM 口径(分/千次展示,SDK getEcpm 原值) |
| `feed_unit_seconds` | int | 信息流每多少秒折 1 份(10) |
| `ecpm_factor_tiers` | `[因子, 下限, 上限\|null][]` | 因子1 档位表(直接读发奖常量,与发奖同源) |
| `lt_factor_tiers` | `[因子, 下限, 上限\|null][]` | 因子2 LT 档位表 |
### AdCoinAuditRow(`items[]`)
| 字段 | 类型 | 说明 |
|---|---|---|
| `scene` | string | `reward_video` / `feed` |
| `record_id` | int | 对应记录表主键 |
| `user_id` | int | |
| `created_at` | datetime | |
| `status` | string | `granted` / `capped`(超每日上限未发) / `ecpm_missing`(缺 eCPM 未发) |
| `ecpm` | string \| null | 本次采用的 eCPM 原始值 |
| `ecpm_factor` | float \| null | 因子1;非 granted 为 null |
| `units` | int | 折算份数:看视频恒 1;信息流 = 满 10 秒份数 |
| `lt_index_start` / `lt_index_end` | int \| null | 本条占用「当日第几份」的起止(看视频起=止;信息流一条可跨多份) |
| `lt_factor_start` / `lt_factor_end` | float \| null | 因子2 的起止值(信息流跨份时会衰减,故给区间) |
| `expected_coin` | int | 按公式复算应发金币(非 granted 恒 0) |
| `actual_coin` | int | 实际入账金币 |
| `matched` | bool | 复算与实发是否一致;非 granted 校验实发是否确为 0 |
## 说明
- **非 granted 行**(capped/ecpm_missing)不占用份序号、应发恒 0,`matched` 用于校验「该不发的确实没发」。
- 用法建议:**按 `user_id`+`date` 定位某次具体核对**最直观;不带 `user_id` 是全用户当天概览。
- 本地联调造数:debug 包看完激励视频会调 [`/api/v1/ad/test-grant`](./ad-test-grant.md),它会用客户端按 `ad_session_id` 上报的真实 eCPM 发奖(取不到兜底 200),所以本审计能看到真实 eCPM 对应的金币。
+7 -2
View File
@@ -3,13 +3,13 @@
> 数据库: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-06-07(补全 22 张业务表 + 新增 [OVERVIEW 总览](./OVERVIEW.md))
> 最后更新:2026-06-10(补登 `meituan_coupon` 美团券缓存表;共 23 张业务表 + [OVERVIEW 总览](./OVERVIEW.md))
> 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。
---
## 表总览(22 张业务表 + `alembic_version` 框架表)
## 表总览(23 张业务表 + `alembic_version` 框架表)
### 账号 / 反馈
| 表 | 用途 | 模型 | 文档 |
@@ -41,6 +41,11 @@
| `savings_record` | 省钱记录(profile 省钱战绩源;真实下单归因 + demo) | `models/savings.py` | [详情](./savings_record.md) |
| `price_report` | 上报更低价(众包纠偏,人工审核发奖) | `models/price_report.py` | [详情](./price_report.md) |
### 美团 CPS 券缓存
| 表 | 用途 | 模型 | 文档 |
|---|---|---|---|
| `meituan_coupon` | 美团 CPS 券本地缓存(智能推荐/销量榜从库出,定时 ETL 灌入) | `models/meituan_coupon.py` | [详情](./meituan_coupon.md) |
### 首页门面数据
| 表 | 用途 | 模型 | 文档 |
|---|---|---|---|
+1 -1
View File
@@ -18,7 +18,7 @@
| `ad_session_id` | String(64) | UNIQUE, index, nullable | 客户端广告会话 ID;用于普通激励视频在 S2S 缺 `ecpm` 时匹配发奖 |
| `adn` | String(32) | nullable | 实际投放 ADN(`getShowEcpm().getSdkName()`,如 `pangle`/`gdt`) |
| `slot_id` | String(64) | nullable | 实际展示用代码位(底层 mediation rit,非客户端配置位) |
| `ecpm_raw` | String(32) | NOT NULL | 客户端上报的 eCPM **原始串**;后端按“元/千次展示”参与金币公式 |
| `ecpm_raw` | String(32) | NOT NULL | 客户端上报的 eCPM **原始串**(穿山甲 getEcpm 原值,单位**分/千次展示**);后端 ÷100 转元参与金币公式 |
| `report_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它做按天聚合 |
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
+58
View File
@@ -0,0 +1,58 @@
# meituan_coupon — 美团 CPS 券本地缓存
> 模型 `app/models/meituan_coupon.py` · ETL `scripts/pull_meituan_coupons.py` · 接口 [meituan-feed](../api/meituan-feed.md)(rec/distance)/ [meituan-top-sales](../api/meituan-top-sales.md)(sales)· [← 索引](./README.md) · [总览](./OVERVIEW.md)
把美团联盟 CPS 的券**定时抓进本地库**,供「智能推荐(佣金≥3%)」「销量最高」从库里捞、本地去重排序,不每次实时打美团(美团对销量排序支持差、有 402 限流和召回上限)。北京单城试点。
## 用在哪 / 增删改查
- **C/U(ETL upsert)**:`scripts/pull_meituan_coupons.py` 每小时全量抓 3 路(`search_waimai` 搜「外卖」/ `search_meishi` 搜「美食」/ `store_supply` 到店多业务线供给),按 `(source, product_view_sign)` upsert(PG `ON CONFLICT DO UPDATE`),`last_seen` 每轮刷新。
- **R(读)**:
- rec(智能推荐):`WHERE commission_percent>=3.0``DISTINCT ON(dedup_key)` 取佣金最高 → 按 `sale_volume_num` 降序分页。
- sales(销量最高):`WHERE sale_volume_num IS NOT NULL``DISTINCT ON(dedup_key)` 取销量最高 → 按销量降序分页。
- **D(清理)**:见下「过期清理策略」。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `source` | String(16) | index, NOT NULL | 召回来源:`search_waimai` / `search_meishi` / `store_supply` |
| `platform` | Integer | NOT NULL | 1 外卖/到家, 2 到店 |
| `biz_line` | Integer | nullable | 到店子类:1到餐 2到综 3酒店 4门票 |
| `city_id` | String(32) | index, NOT NULL | 城市 id(当前恒为北京 `WKV2HMXUEK634WP64CUCUQGM64`) |
| `product_view_sign` | String(128) | NOT NULL | 换链主键;**按召回渠道生成、跨渠道会变**,不能当商品全局 id |
| `sku_view_id` | String(128) | nullable | |
| `name` | String(256) | nullable | 商品名(解析截断 `[:256]`) |
| `brand_name` | String(128) | index, nullable | 品牌名(`[:128]`) |
| `sell_price_cents` | Integer | nullable | 现价(分) |
| `original_price_cents` | Integer | nullable | 原价(分) |
| `head_url` | String(512) | nullable | 头图(去 `@` 后缀,`[:512]`) |
| `sale_volume` | String(32) | nullable | 原始销量档:如「热销1w+」 |
| `sale_volume_num` | Integer | index, nullable | 销量排序数值(取下界):`1w+` → 10000 |
| `commission_percent` | Float | index, nullable | 佣金比例,**数值即百分数**(1.4 = 1.4%);美团原值 140 ÷ 100 |
| `commission_amount_cents` | Integer | nullable | 预估佣金(分) |
| `poi_name` | String(128) | nullable | 最近门店名(`[:128]`) |
| `available_poi_num` | Integer | nullable | 可用门店数 |
| `delivery_distance_m` | Float | nullable | 距离(米);**相对抓取时的城市默认点**,对用户位置无意义,rec 接口置空不返 |
| `dedup_key` | String(64) | index, NOT NULL | 跨源去重键 = `md5(brand_name\|name\|sell_price_cents)` |
| `raw` | JSON / JSONB | NOT NULL, default `{}` | 整条原始返回(避免漏字段重抓);PG 上为 JSONB |
| `first_seen` | DateTime(tz) | server_default now() | 首次抓到 |
| `last_seen` | DateTime(tz) | index, server_default now() | 最近一轮抓到(**过期清理依据**) |
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | |
## 索引与约束
- 唯一约束 `uq_meituan_coupon_source_sign`(`source`, `product_view_sign`)—— ETL upsert 的冲突目标。
- 单列 index:`source``city_id``brand_name``sale_volume_num``commission_percent``dedup_key``last_seen`
- **字段核对(2026-06-10)**:模型 ↔ 迁移(`meituan_coupon_table`)↔ ETL 解析三处一致;解析截断宽度均 ≤ 列宽(name256 / brand128 / poi128 / head512 / sign128,`dedup_key` = md5 32 字符 ≤ 64)。
- **索引评估**:`rec` / `sales` 查询的理想索引是复合 `(dedup_key, sale_volume_num DESC)``(dedup_key, commission_percent DESC)`(匹配 `DISTINCT ON`);但当前**单城几千行,seq scan 即亚毫秒**,现有单列索引已够用,复合索引留作多城 / 放量时再加,避免过早优化。
## 过期清理策略
- ETL 每轮(每小时)末尾按 **`last_seen` 的 TTL** 清理:`DELETE WHERE last_seen < now() - prune_hours`(默认 24h,`--prune-hours` 可调,0=不清)。
- 仍在架的券每小时被刷新 `last_seen`,**永不被清**;只有下架 / `sign` 轮换的残留超 24h 宽限才删。宽限期用于吸收几次抓取失败。
- 券自身到期**无需单独处理**:到期后美团不再返回 → 自然 `last_seen` 老化被清。
- **护栏(2026-06-10)**:prune 仅在**本轮确有入库(`total>0`)**时执行。美团整体故障时本轮可能 0 入库(脚本不抛异常、只是抓回空),若仍照常 prune,连续故障会按 `last_seen` **把全表删空**;加 `total>0` 护栏后,0 入库轮跳过清理并打日志。
- 表只增量 upsert + 少量 delete,PG autovacuum 足以回收死元组,当前规模无需手动 VACUUM。
## 注意
- **仅 PostgreSQL**:ETL upsert(`postgresql.insert(...).on_conflict_do_update`)与读取的 `DISTINCT ON` 都是 PG 专用语法;库若是 sqlite,ETL 灌不进、rec/sales 接口报错。prod `DATABASE_URL` 必须 `postgresql+psycopg://`
- **`dedup_key` 不含城市**:`md5(brand|name|price)`,单城没问题;将来多城会把异城同品合并,需把 `city_id` 纳入 key。
- `product_view_sign` 跨渠道会变,**不能当商品全局唯一 id**:存储按 `(source, sign)`、查询按 `dedup_key`
+3
View File
@@ -34,6 +34,9 @@ dependencies = [
# admin 后台账号密码 hash(用户侧是手机号+验证码登录,不需要密码;admin 才用)
"bcrypt>=4.0.0",
# 邀请指纹归因:解析浏览器 UA 拿手机型号(Build.MODEL),跨端匹配用
"user-agents>=2.2.0",
]
[project.optional-dependencies]
+11 -3
View File
@@ -22,6 +22,7 @@ import hashlib
import os
import re
import sys
import tempfile
import time
from datetime import datetime, timedelta, timezone
@@ -44,7 +45,10 @@ PAGE_SIZE = 20
MAX_PAGES = 80 # 单路安全上限(搜索 ~52 页、供给 ~70 页)
PAGE_SLEEP = 0.35 # 页间配速,缓解 402
RETRY = 7
LOCK_FILE = "data/.meituan_etl.lock"
# 运行锁默认放系统临时目录(任何账号可写),不依赖代码目录 data/ 的写权限——
# 无 sudo 部署时 data/ 常属 root,cps 写不了会导致每轮 PermissionError、cron 抓不进数据。
# 需要指定位置时用环境变量 MEITUAN_ETL_LOCK 覆盖。
LOCK_FILE = os.environ.get("MEITUAN_ETL_LOCK") or os.path.join(tempfile.gettempdir(), "meituan_etl.lock")
LOCK_STALE_SEC = 30 * 60 # 锁超过 30min 视为陈旧(进程异常退出残留),自动接管
SOURCES = [
@@ -288,8 +292,10 @@ def run_once(prune_hours: int = 24) -> None:
total += up
print(f" {src['label']:18}{len(items):5} 解析{len(parsed):5} "
f"入库{up:5} (本源去重{dup:3}) {time.time() - ts:4.0f}s")
# 清理长期未再出现的陈旧券(美团 sign 轮换 / 券下架后的残留),默认 24h 宽限
if prune_hours and prune_hours > 0:
# 清理长期未再出现的陈旧券(美团 sign 轮换 / 券下架后的残留),默认 24h 宽限
# 护栏:仅在本轮确有入库(total>0)时才清理。美团整体故障时本轮可能 0 入库
# (脚本不抛异常、只是抓回空),若仍照常 prune,连续故障会按 last_seen 把全表删空。
if prune_hours and prune_hours > 0 and total > 0:
cutoff = now - timedelta(hours=prune_hours)
pruned = db.execute(
delete(MeituanCoupon).where(MeituanCoupon.last_seen < cutoff)
@@ -297,6 +303,8 @@ def run_once(prune_hours: int = 24) -> None:
db.commit()
if pruned:
print(f" 清理陈旧券(>{prune_hours}h 未再出现): {pruned}")
elif prune_hours and prune_hours > 0 and total == 0:
print(" 本轮 0 入库(疑似上游故障),跳过清理以防误删全表")
cnt = db.execute(select(func.count()).select_from(MeituanCoupon)).scalar()
print(f"[{datetime.now():%H:%M:%S}] 本轮完成: 入库 {total} 条, 表总计 {cnt} 行, "
f"用时 {time.time() - t0:.0f}s")
+91
View File
@@ -0,0 +1,91 @@
# -*- coding: utf-8 -*-
"""造测试数据验证邀请列表"真实头像能不能连上"
A 邀请 B1(上传真实头像)/B2(无头像色块)/B3(设昵称色块), 然后:
打印 A 的邀请码 + /invitees 返回( B1 avatar_urlB2 B3 昵称);
直接 HTTP 访问 B1 的头像地址,确认文件能取到(后端静态托管通)
前端 AsyncImage 实际显示,装机后真机用 A 登录看
: <app-server py> scripts/seed_invitee_avatar_test.py"""
import io
import struct
import sys
import zlib
import httpx
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
BASE = "http://127.0.0.1:8770"
def solid_png(size: int = 96, rgb: tuple = (80, 140, 240)) -> bytes:
"""生成一张纯色 PNG(蓝色 96x96),够真机看清、魔数是合法 PNG。"""
row = b"\x00" + bytes(rgb) * size
raw = row * size
comp = zlib.compress(raw)
def chunk(typ: bytes, data: bytes) -> bytes:
c = typ + data
return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF)
sig = b"\x89PNG\r\n\x1a\n"
ihdr = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0) # 8bit, RGB
return sig + chunk(b"IHDR", ihdr) + chunk(b"IDAT", comp) + chunk(b"IEND", b"")
def login(c: httpx.Client, phone: str) -> str:
c.post(f"{BASE}/api/v1/auth/sms/send", json={"phone": phone})
r = c.post(f"{BASE}/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
r.raise_for_status()
return r.json()["access_token"]
def auth(tok: str) -> dict:
return {"Authorization": f"Bearer {tok}"}
with httpx.Client(timeout=15) as c:
# A = 邀请人(真机用它登录看)
A_PHONE = "13900008001"
a_tok = login(c, A_PHONE)
a_code = c.get(f"{BASE}/api/v1/invite/me", headers=auth(a_tok)).json()["invite_code"]
print(f"邀请人 A: 手机号={A_PHONE} 验证码=123456 邀请码={a_code}")
# B1: 绑 A 码 + 上传真实头像
b1 = login(c, "13900008002")
c.post(f"{BASE}/api/v1/invite/bind", json={"invite_code": a_code}, headers=auth(b1))
r = c.post(
f"{BASE}/api/v1/user/avatar",
headers=auth(b1),
files={"file": ("avatar.png", io.BytesIO(solid_png()), "image/png")},
)
print(f"B1 绑定 + 上传头像 -> HTTP {r.status_code}, avatar_url={r.json().get('avatar_url')!r}")
# B2: 绑 A 码, 不传头像(测色块兜底)
b2 = login(c, "13900008003")
c.post(f"{BASE}/api/v1/invite/bind", json={"invite_code": a_code}, headers=auth(b2))
print("B2 绑定, 无头像(测色块)")
# B3: 绑 A 码 + 设昵称(测昵称优先 + 色块)
b3 = login(c, "13900008004")
c.post(f"{BASE}/api/v1/invite/bind", json={"invite_code": a_code}, headers=auth(b3))
c.patch(f"{BASE}/api/v1/user/profile", json={"nickname": "测试昵称"}, headers=auth(b3))
print("B3 绑定 + 昵称='测试昵称'")
# A 的邀请列表
inv = c.get(f"{BASE}/api/v1/invite/invitees", headers=auth(a_tok)).json()
print(f"\n=== A 的 /invitees (共 {inv['total']} 人) ===")
for it in inv["items"]:
print(f" name={it['display_name']!r} avatar={it['avatar_url']!r} coins={it['coins']} time={it['invited_at']}")
# 关键: 直接 HTTP 取 B1 的头像文件, 确认后端静态托管能 serve(= 前端拿这地址也能加载)
b1_avatar = next((it["avatar_url"] for it in inv["items"] if it["avatar_url"]), None)
print("\n=== 头像文件 HTTP 可访问性(后端静态托管) ===")
if b1_avatar:
rr = c.get(f"{BASE}{b1_avatar}")
ok = rr.status_code == 200 and (rr.headers.get("content-type", "").startswith("image"))
print(f" GET {BASE}{b1_avatar}")
print(f" -> HTTP {rr.status_code}, content-type={rr.headers.get('content-type')}, {len(rr.content)} bytes {'✅ 能取到' if ok else '✗ 取不到'}")
else:
print(" 没有带头像的被邀请人?!")
+218 -1
View File
@@ -1,4 +1,4 @@
"""好友邀请测试:邀请码、绑定、双方发金币、幂等、自邀/无效码屏蔽。
"""好友邀请测试:邀请码、绑定、双方发金币、幂等、自邀/无效码屏蔽、指纹兜底归因
sms mock 登录拿 token( test_welfare),再跑邀请闭环
"""
@@ -6,12 +6,16 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from app.core.rewards import (
INVITE_FP_WINDOW_DAYS,
INVITE_INVITEE_COINS,
INVITE_INVITER_COINS,
INVITE_NEW_USER_WINDOW_HOURS,
)
from app.db.session import SessionLocal
from app.models.invite_fingerprint import InviteFingerprint
from app.repositories.user import get_user_by_phone
@@ -158,3 +162,216 @@ def test_old_user_not_eligible(client) -> None:
assert r.json()["coins_awarded"] == 0
assert _coin_balance(client, b) == 0
assert _coin_balance(client, a) == 0
# =====================================================================
# 任务 3:指纹归因(剪贴板兜底,见 brain [[invite-three-tasks]])
# =====================================================================
def test_landing_track_records_fingerprint(client) -> None:
"""落地页 POST /landing-track 成功存指纹(无需鉴权)。"""
a = _login(client, "13800002040")
a_code = _my_code(client, a)
# 浏览器无 token 调
r = client.post(
"/api/v1/invite/landing-track",
json={"ref": a_code, "screen": "1080x2400"},
)
assert r.status_code == 200, r.text
assert r.json()["status"] == "ok"
def test_landing_track_invalid_code(client) -> None:
"""无效邀请码 → invalid_code(silent 返,不影响落地页下载流程)。"""
r = client.post(
"/api/v1/invite/landing-track",
json={"ref": "ZZZZZZ", "screen": "1080x2400"},
)
assert r.status_code == 200, r.text
assert r.json()["status"] == "invalid_code"
def test_bind_by_fingerprint_success(client) -> None:
"""B 落地页存指纹 → B 登录后用指纹兜底绑定成功(模拟剪贴板被覆盖丢失)。"""
a = _login(client, "13800002041")
a_code = _my_code(client, a)
# 1. B 浏览器(无 token)访问落地页 → 存指纹
r1 = client.post(
"/api/v1/invite/landing-track",
json={"ref": a_code, "screen": "1234x5678"}, # 用独特 screen 避免跟其它测试撞
)
assert r1.json()["status"] == "ok"
# 2. B 注册登录(剪贴板被覆盖,没拿到邀请码)
b = _login(client, "13800002042")
# 3. B 不带 invite_code,只带 fingerprint 走兜底
r2 = client.post(
"/api/v1/invite/bind",
json={
"channel": "fingerprint",
"fingerprint": {"screen": "1234x5678", "device_model": ""},
},
headers=_auth(b),
)
assert r2.status_code == 200, r2.text
assert r2.json()["status"] == "success"
# 双方各发金币
assert _coin_balance(client, a) == INVITE_INVITER_COINS
assert _coin_balance(client, b) == INVITE_INVITEE_COINS
def test_bind_by_fingerprint_not_found(client) -> None:
"""没有匹配的指纹记录 → fp_not_found,不发奖。"""
b = _login(client, "13800002043")
r = client.post(
"/api/v1/invite/bind",
json={
"channel": "fingerprint",
"fingerprint": {"screen": "0000x9999", "device_model": "no_such_model"},
},
headers=_auth(b),
)
assert r.status_code == 200, r.text
assert r.json()["status"] == "fp_not_found"
assert r.json()["coins_awarded"] == 0
assert _coin_balance(client, b) == 0
def test_bind_by_fingerprint_outside_window(client) -> None:
"""指纹记录超出 INVITE_FP_WINDOW_DAYS 窗口 → fp_not_found。
用独特设备型号隔离:测试环境所有请求 IP 都是 testclient落地页默认无 UA(解析出的
型号="")而指纹反查按 (IP, 型号) 匹配 会串到别的测试残留的没过期的 model=""
指纹上(误判 success)这里给落地页传一个独特 UA(解析出独特型号 OUTWIN9X),反查只可能
命中本测试自己的指纹,"过期就反查不到"才验得准
"""
a = _login(client, "13800002044")
a_code = _my_code(client, a)
# 落地页存指纹(独特 UA → 独特型号 OUTWIN9X),再手动把 created_at 改到窗口外
unique_screen = "2222x3333"
unique_ua = "Mozilla/5.0 (Linux; Android 13; OUTWIN9X Build/TQ) AppleWebKit/537.36"
r1 = client.post(
"/api/v1/invite/landing-track",
json={"ref": a_code, "screen": unique_screen},
headers={"user-agent": unique_ua},
)
assert r1.json()["status"] == "ok"
with SessionLocal() as db:
fp = db.execute(
select(InviteFingerprint).where(InviteFingerprint.screen == unique_screen)
).scalar_one()
fp.created_at = datetime.now(timezone.utc) - timedelta(days=INVITE_FP_WINDOW_DAYS + 1)
db.commit()
b = _login(client, "13800002045")
r = client.post(
"/api/v1/invite/bind",
json={
"channel": "fingerprint",
"fingerprint": {"screen": unique_screen, "device_model": "OUTWIN9X"},
},
headers=_auth(b),
)
assert r.status_code == 200, r.text
assert r.json()["status"] == "fp_not_found"
assert _coin_balance(client, a) == 0
assert _coin_balance(client, b) == 0
def test_bind_no_code_no_fingerprint(client) -> None:
"""既没邀请码也没指纹 → invalid_code(无效请求)。"""
b = _login(client, "13800002046")
r = client.post(
"/api/v1/invite/bind",
json={"channel": "fingerprint"}, # 没 invite_code、没 fingerprint
headers=_auth(b),
)
assert r.status_code == 200, r.text
assert r.json()["status"] == "invalid_code"
assert _coin_balance(client, b) == 0
# =====================================================================
# 被邀请人列表 GET /invitees(邀请页小窗 + 完整列表页共用)
# =====================================================================
def test_invitees_basic(client) -> None:
"""A 邀 B、C → 列表返 2 条、total=2、没设资料的名字=脱敏手机号、头像 null、金币对。"""
a = _login(client, "13800002050")
a_code = _my_code(client, a)
for phone in ("13800002051", "13800002052"):
u = _login(client, phone)
client.post("/api/v1/invite/bind", json={"invite_code": a_code}, headers=_auth(u))
r = client.get("/api/v1/invite/invitees", headers=_auth(a))
assert r.status_code == 200, r.text
body = r.json()
assert body["total"] == 2
assert body["has_more"] is False
assert len(body["items"]) == 2
# 没设昵称头像 → 名字脱敏手机号、头像 null(不依赖顺序用 set)
names = {it["display_name"] for it in body["items"]}
assert names == {"138****2051", "138****2052"}
assert all(it["avatar_url"] is None for it in body["items"])
assert all(it["coins"] == INVITE_INVITER_COINS for it in body["items"])
def test_invitees_order_desc(client) -> None:
"""按邀请时间倒序:最近邀请的在最前(手动拉开时间,避开 SQLite 秒级精度)。"""
from app.models.invite import InviteRelation
a = _login(client, "13800002060")
a_code = _my_code(client, a)
b = _login(client, "13800002061")
c = _login(client, "13800002062")
client.post("/api/v1/invite/bind", json={"invite_code": a_code}, headers=_auth(b))
client.post("/api/v1/invite/bind", json={"invite_code": a_code}, headers=_auth(c))
with SessionLocal() as db:
ub = get_user_by_phone(db, "13800002061")
rel_b = db.execute(
select(InviteRelation).where(InviteRelation.invitee_user_id == ub.id)
).scalar_one()
rel_b.created_at = datetime.now(timezone.utc) - timedelta(hours=2)
db.commit()
items = client.get("/api/v1/invite/invitees", headers=_auth(a)).json()["items"]
assert items[0]["display_name"] == "138****2062" # C 刚邀,在前
assert items[1]["display_name"] == "138****2061" # B 2 小时前,在后
def test_invitees_nickname_priority(client) -> None:
"""设了昵称的被邀请人 → 名字用昵称(优先于脱敏手机号)。"""
a = _login(client, "13800002070")
a_code = _my_code(client, a)
b = _login(client, "13800002071")
client.post("/api/v1/invite/bind", json={"invite_code": a_code}, headers=_auth(b))
with SessionLocal() as db:
u = get_user_by_phone(db, "13800002071")
u.nickname = "小明"
db.commit()
items = client.get("/api/v1/invite/invitees", headers=_auth(a)).json()["items"]
assert items[0]["display_name"] == "小明"
def test_invitees_pagination(client) -> None:
"""分页:limit=1 → 1 条 + has_more=True;offset=1 → 第 2 条 + has_more=False。"""
a = _login(client, "13800002080")
a_code = _my_code(client, a)
for phone in ("13800002081", "13800002082"):
u = _login(client, phone)
client.post("/api/v1/invite/bind", json={"invite_code": a_code}, headers=_auth(u))
p1 = client.get("/api/v1/invite/invitees?limit=1&offset=0", headers=_auth(a)).json()
assert len(p1["items"]) == 1 and p1["total"] == 2 and p1["has_more"] is True
p2 = client.get("/api/v1/invite/invitees?limit=1&offset=1", headers=_auth(a)).json()
assert len(p2["items"]) == 1 and p2["has_more"] is False
def test_invitees_empty_and_auth(client) -> None:
"""没邀请人 → 空列表;不带 token → 401。"""
a = _login(client, "13800002090")
body = client.get("/api/v1/invite/invitees", headers=_auth(a)).json()
assert body["total"] == 0 and body["items"] == [] and body["has_more"] is False
assert client.get("/api/v1/invite/invitees").status_code == 401