Compare commits

..

8 Commits

Author SHA1 Message Date
zzhyyyyy dacb37e3e1 feat(onboarding): 新手引导完成按 设备+账号 去重(表/模型/仓储/端点/迁移/测试)
- onboarding_completion 表(user_id+device_id 唯一约束)+ model + repository
- POST /api/v1/user/onboarding/complete 标记完成;登录响应 TokenWithUser.onboarding_completed
  (按登录请求带的 device_id 判定是否已走过引导,跨卸载重装稳定)
- alembic 迁移 onboarding_completion(rebase 到最新 main 后已链在 coupon_daily_completion 之后,单 head)
- docs/database 文档 + tests/test_onboarding.py + run.bat(本地启动脚本)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:32:51 +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
39 changed files with 1171 additions and 53 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")
@@ -1,18 +1,16 @@
"""add user.debug_trace_enabled + comparison_record.trace_url(并合并既存双 head """add user.debug_trace_enabled + comparison_record.trace_url
调试链接权限功能: 调试链接权限功能:
- user.debug_trace_enabled:运营后台给指定用户开「复制调试链接」权限 - user.debug_trace_enabled:运营后台给指定用户开「复制调试链接」权限
- comparison_record.trace_url:比价记录页「复制调试链接」的数据(pricebot done 帧给, - comparison_record.trace_url:比价记录页「复制调试链接」的数据(pricebot done 帧给,
dir 名含落盘时分秒、前端/server 拼不出,必须落库) dir 名含落盘时分秒、前端/server 拼不出,必须落库)
⚠️ 本迁移 down_revision 是 **tuple**,同时合并了 clone 时 main 上既存的双 head 注:本迁移最初基于旧 main#31,双 head)写、down_revision 曾指向那对父;但 #33 已用
- invite_fingerprint_table #31 被邀请人列表 + 指纹归因 那条线) a8c47fc4dc39 合并了那对双 head,故改为线性接在 a8c47fc4dc39 之后、只负责加两列
- 044dce6e9b1f merge_ad_feed_reward_session + withdraw_safety_indexes (避免与 a8c47fc4dc39 平行再造一个双 head)。
这两个头平行未合并,`alembic upgrade head` 会报 Multiple heads。本迁移顺手收口成单 head。
若想把「合并双 head」与「加列」拆开,可改成:先一个纯 merge 迁移、再一个加列迁移。
Revision ID: f8d3b1e60a27 Revision ID: f8d3b1e60a27
Revises: invite_fingerprint_table, 044dce6e9b1f Revises: a8c47fc4dc39
Create Date: 2026-06-10 02:40:00.000000 Create Date: 2026-06-10 02:40:00.000000
""" """
@@ -24,10 +22,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "f8d3b1e60a27" revision: str = "f8d3b1e60a27"
down_revision: Union[str, Sequence[str], None] = ( down_revision: Union[str, Sequence[str], None] = "a8c47fc4dc39"
"invite_fingerprint_table",
"044dce6e9b1f",
)
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
@@ -0,0 +1,39 @@
"""onboarding_completion table (新手引导完成标记,按 user+device 去重)
Revision ID: onboarding_completion
Revises: coupon_daily_completion
Create Date: 2026-06-10 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'onboarding_completion'
down_revision: Union[str, Sequence[str], None] = 'coupon_daily_completion'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# (账号, 设备) 一条完成标记;device_id = 客户端硬件级 ANDROID_ID(跨卸载重装稳定)。
op.create_table(
'onboarding_completion',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('device_id', sa.String(length=64), nullable=False),
sa.Column('completed_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'device_id', name='uq_onboarding_user_device'),
)
with op.batch_alter_table('onboarding_completion', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_onboarding_completion_user_id'), ['user_id'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('onboarding_completion', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_onboarding_completion_user_id'))
op.drop_table('onboarding_completion')
+4
View File
@@ -13,6 +13,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware 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.admins import router as admins_router
from app.admin.routers.audit import router as audit_router from app.admin.routers.audit import router as audit_router
from app.admin.routers.auth import router as auth_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.ops_stat_config import router as ops_stat_config_router
from app.admin.routers.feedback import router as feedback_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.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.users import router as users_router
from app.admin.routers.wallet import router as wallet_router from app.admin.routers.wallet import router as wallet_router
from app.admin.routers.withdraw import router as withdraw_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(users_router)
admin_app.include_router(wallet_router) admin_app.include_router(wallet_router)
admin_app.include_router(withdraw_router) admin_app.include_router(withdraw_router)
admin_app.include_router(price_report_router)
admin_app.include_router(feedback_router) admin_app.include_router(feedback_router)
admin_app.include_router(admins_router) admin_app.include_router(admins_router)
admin_app.include_router(audit_router) admin_app.include_router(audit_router)
admin_app.include_router(config_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],
}
+31
View File
@@ -8,9 +8,13 @@ set_user_status / update_feedback_status 支持 commit=False,让 router 把"业
""" """
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ
from app.models.feedback import Feedback from app.models.feedback import Feedback
from app.models.price_report import PriceReport
from app.models.user import User from app.models.user import User
@@ -48,3 +52,30 @@ def update_feedback_status(
else: else:
db.flush() db.flush()
return feedback 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.admin import AdminAuditLog
from app.models.comparison import ComparisonRecord from app.models.comparison import ComparisonRecord
from app.models.feedback import Feedback from app.models.feedback import Feedback
from app.models.price_report import PriceReport
from app.models.user import User from app.models.user import User
from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder 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(), ).scalar_one(),
"feedback_total": _count(Feedback, Feedback.user_id == user_id), "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()
+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
+10 -2
View File
@@ -304,10 +304,18 @@ def test_grant(user: CurrentUser, db: DbSession, payload: TestGrantIn | None = N
db.commit() db.commit()
db.refresh(rec) db.refresh(rec)
else: 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: try:
rec = crud_ad.grant_ad_reward( rec = crud_ad.grant_ad_reward(
db, user.id, trans_id, ecpm="200", reward_name="测试发奖", db, user.id, trans_id, ecpm=ecpm_val, ad_session_id=ad_session_id,
raw="client debug test-grant ecpm=200", reward_name="测试发奖", raw=f"client debug test-grant ecpm={ecpm_val}",
) )
except crud_ad.UnknownUserError as e: except crud_ad.UnknownUserError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e
+10 -6
View File
@@ -19,6 +19,7 @@ from app.core.ratelimit import rate_limit
from app.core.security import TokenError, decode_token, issue_token_pair from app.core.security import TokenError, decode_token, issue_token_pair
from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone
from app.integrations.sms import SmsError, send_code, verify_code from app.integrations.sms import SmsError, send_code, verify_code
from app.repositories import onboarding as onboarding_repo
from app.repositories import user as user_repo from app.repositories import user as user_repo
from app.schemas.auth import ( from app.schemas.auth import (
JverifyLoginRequest, JverifyLoginRequest,
@@ -37,12 +38,13 @@ logger = logging.getLogger("shagua.auth")
router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
def _login_response(db, user) -> TokenWithUser: def _login_response(user, *, onboarding_completed: bool) -> TokenWithUser:
"""登录类接口共用的响应组装。""" """登录类接口共用的响应组装。onboarding_completed 据登录请求的 device_id 算好后传入。"""
tokens = issue_token_pair(user.id) tokens = issue_token_pair(user.id)
return TokenWithUser( return TokenWithUser(
**tokens, **tokens,
user=UserOut.model_validate(user), user=UserOut.model_validate(user),
onboarding_completed=onboarding_completed,
) )
@@ -66,8 +68,9 @@ def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser:
if user.status != "active": if user.status != "active":
raise HTTPException(status_code=403, detail="account disabled") raise HTTPException(status_code=403, detail="account disabled")
logger.info("jverify_login ok user_id=%d phone=%s", user.id, mask_phone(phone)) completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id)
return _login_response(db, user) logger.info("jverify_login ok user_id=%d phone=%s onboarded=%s", user.id, mask_phone(phone), completed)
return _login_response(user, onboarding_completed=completed)
# ===================== 短信登录 ===================== # ===================== 短信登录 =====================
@@ -103,8 +106,9 @@ def sms_login(req: SmsLoginRequest, db: DbSession) -> TokenWithUser:
if user.status != "active": if user.status != "active":
raise HTTPException(status_code=403, detail="account disabled") raise HTTPException(status_code=403, detail="account disabled")
logger.info("sms_login ok user_id=%d phone=%s", user.id, mask_phone(req.phone)) completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id)
return _login_response(db, user) logger.info("sms_login ok user_id=%d phone=%s onboarded=%s", user.id, mask_phone(req.phone), completed)
return _login_response(user, onboarding_completed=completed)
# ===================== Refresh ===================== # ===================== Refresh =====================
+42 -1
View File
@@ -23,7 +23,11 @@ from app.core.config import settings
from app.core.pricebot_router import pick_pricebot from app.core.pricebot_router import pick_pricebot
from app.db.session import SessionLocal from app.db.session import SessionLocal
from app.repositories import coupon_state as coupon_repo 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") 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) 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)") @router.post("/step", summary="领券任务步进 (透传到 pricebot)")
async def coupon_step( async def coupon_step(
request: Request, request: Request,
@@ -160,6 +172,19 @@ async def coupon_step(
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
logger.warning("coupon claim write failed: %s", e) 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 return resp_json
@@ -195,3 +220,19 @@ def coupon_prompt_reset(payload: CouponPromptDismissIn, db: DbSession) -> dict[s
开发设置重置今日领券弹窗状态按钮调MVP 不鉴权, device_id""" 开发设置重置今日领券弹窗状态按钮调MVP 不鉴权, device_id"""
coupon_repo.reset_today_engagement(db, payload.device_id) coupon_repo.reset_today_engagement(db, payload.device_id)
return {"ok": True} 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)
)
+13 -1
View File
@@ -16,9 +16,10 @@ from fastapi import APIRouter, File, HTTPException, UploadFile
from app.api.deps import CurrentUser, DbSession from app.api.deps import CurrentUser, DbSession
from app.core import media from app.core import media
from app.repositories import onboarding as onboarding_repo
from app.repositories import user as user_repo from app.repositories import user as user_repo
from app.schemas.auth import UserOut from app.schemas.auth import UserOut
from app.schemas.user import OkResponse, ProfileUpdateRequest from app.schemas.user import OkResponse, OnboardingCompleteRequest, ProfileUpdateRequest
logger = logging.getLogger("shagua.user") logger = logging.getLogger("shagua.user")
@@ -51,6 +52,17 @@ async def upload_avatar(
return UserOut.model_validate(updated) return UserOut.model_validate(updated)
@router.post("/onboarding/complete", response_model=OkResponse, summary="标记新手引导完成(按 设备+账号)")
def complete_onboarding(
req: OnboardingCompleteRequest, user: CurrentUser, db: DbSession
) -> OkResponse:
"""走完新手引导时调一次。按 (当前账号, device_id) 落一条完成标记,跨卸载重装持久。
幂等:重复调用不报错device_id 取客户端硬件级 ANDROID_ID,与登录请求一致"""
onboarding_repo.mark_completed(db, user_id=user.id, device_id=req.device_id)
logger.info("onboarding complete user_id=%d device_len=%d", user.id, len(req.device_id))
return OkResponse()
@router.delete("", response_model=OkResponse, summary="注销账号(软删除)") @router.delete("", response_model=OkResponse, summary="注销账号(软删除)")
def delete_account(user: CurrentUser, db: DbSession) -> OkResponse: def delete_account(user: CurrentUser, db: DbSession) -> OkResponse:
media.delete_avatar(user.avatar_url) media.delete_avatar(user.avatar_url)
+23 -5
View File
@@ -77,6 +77,12 @@ def record_milestone_reward(milestone: int) -> int:
return RECORD_MILESTONES[milestone - 1] return RECORD_MILESTONES[milestone - 1]
# ===== 上报更低价(人工审核通过发固定金币)=====
# 用户上报"某平台比我们算的最低价更便宜"+ 截图,经运营后台人工审核通过后发放的固定金币奖励。
# 与广告/任务同量级;固定值(产品 2026-06 定),要调直接改这里;客户端记录页按 reward_coins 显示。
PRICE_REPORT_REWARD_COINS: int = 1000
# ===== 邀请好友(注册即生效,邀请人 + 被邀请人各发金币)===== # ===== 邀请好友(注册即生效,邀请人 + 被邀请人各发金币)=====
# 10000 金币 = 1 元,双方各得 1 元。MVP 先用固定常量(不走 app_config)。 # 10000 金币 = 1 元,双方各得 1 元。MVP 先用固定常量(不走 app_config)。
INVITE_INVITER_COINS: int = 10000 INVITE_INVITER_COINS: int = 10000
@@ -92,7 +98,11 @@ 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], ...] = ( AD_ECPM_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = (
(0.1, 0, 100), (0.1, 0, 100),
(0.3, 101, 200), (0.3, 101, 200),
@@ -108,8 +118,8 @@ AD_LT_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = (
) )
def parse_ecpm_yuan(ecpm: str | int | float | None) -> float: def parse_ecpm_fen(ecpm: str | int | float | None) -> float:
"""解析 eCPM 原始值。当前产品口径:SDK 返回值按"元/千次展示"处理""" """解析 eCPM 原始值(穿山甲 getEcpm 原值,单位=分/千次展示)。非法/缺失→0"""
if ecpm is None: if ecpm is None:
return 0.0 return 0.0
try: try:
@@ -119,8 +129,14 @@ def parse_ecpm_yuan(ecpm: str | int | float | None) -> float:
return max(0.0, value) 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: 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: if ecpm_yuan > 400:
return 0.6 return 0.6
if ecpm_yuan > 200: if ecpm_yuan > 200:
@@ -142,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: 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) ecpm_yuan = parse_ecpm_yuan(ecpm)
yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(today_count_after_this) yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(today_count_after_this)
+2
View File
@@ -9,12 +9,14 @@ from app.models.comparison import ComparisonRecord # noqa: F401
from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401 from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401
from app.models.coupon_state import ( # noqa: F401 from app.models.coupon_state import ( # noqa: F401
CouponClaimRecord, CouponClaimRecord,
CouponDailyCompletion,
CouponPromptEngagement, CouponPromptEngagement,
) )
from app.models.feedback import Feedback # noqa: F401 from app.models.feedback import Feedback # noqa: F401
from app.models.invite import InviteRelation # noqa: F401 from app.models.invite import InviteRelation # noqa: F401
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401 from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
from app.models.meituan_coupon import MeituanCoupon # noqa: F401 from app.models.meituan_coupon import MeituanCoupon # noqa: F401
from app.models.onboarding import OnboardingCompletion # noqa: F401
from app.models.ops_marquee_seed import OpsMarqueeSeed # noqa: F401 from app.models.ops_marquee_seed import OpsMarqueeSeed # noqa: F401
from app.models.ops_stat_config import OpsStatConfig # noqa: F401 from app.models.ops_stat_config import OpsStatConfig # noqa: F401
from app.models.price_observation import PriceObservation # noqa: F401 from app.models.price_observation import PriceObservation # noqa: F401
+1 -1
View File
@@ -35,7 +35,7 @@ class AdEcpmRecord(Base):
adn: Mapped[str | None] = mapped_column(String(32), nullable=True) adn: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 实际展示用的代码位(底层 mediation rit,非客户端配置位) # 实际展示用的代码位(底层 mediation rit,非客户端配置位)
slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True) 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) ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False)
# 北京时间日期串 'YYYY-MM-DD',按它等值做"按天聚合"(不在 SQL 里做跨时区 date 比较) # 北京时间日期串 'YYYY-MM-DD',按它等值做"按天聚合"(不在 SQL 里做跨时区 date 比较)
report_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False) report_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False)
+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): class CouponPromptEngagement(Base):
"""按 (device, 自然日) 记"今天是否对领券引导窗表达过意向"——弹窗频控源。""" """按 (device, 自然日) 记"今天是否对领券引导窗表达过意向"——弹窗频控源。"""
+40
View File
@@ -0,0 +1,40 @@
"""新手引导完成标记(按 设备 + 账号 去重)。
产品规则:同一台设备 + 同一个账号,新手引导只跑一次;卸载重装(同设备同账号)
不再触发本地标记(SharedPreferences)卸载即丢,所以"完成"必须落后端,
(user_id, device_id) 唯一一条
device_id 这里用客户端的**硬件级稳定标识**(Android `Settings.Secure.ANDROID_ID`),
同签名 app 卸载重装不变仅恢复出厂才重置区别于领券/比价用的 per-install device_id
( coupon_state, SP重装会变)换新设备 无此行 重新走引导
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class OnboardingCompletion(Base):
"""一台设备 + 一个账号 一条:走完新手引导即写入,登录时据此跳过引导。"""
__tablename__ = "onboarding_completion"
__table_args__ = (
# 同账号、同设备只一条:重复 mark / 并发提交靠它幂等。
UniqueConstraint("user_id", "device_id", name="uq_onboarding_user_device"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 登录态用户。引导是登录后才走的,故必填(非空)、进唯一键。
user_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
# 硬件级稳定设备标识(ANDROID_ID),卸载重装不变。
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
completed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<OnboardingCompletion user={self.user_id} device={self.device_id}>"
+46 -1
View File
@@ -13,7 +13,11 @@ from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session 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") logger = logging.getLogger("shagua.coupon_state")
@@ -78,6 +82,47 @@ def reset_today_engagement(db: Session, device_id: str) -> int:
return result.rowcount or 0 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)===== # ===== 领券记录(coupon_claim_record)=====
def record_claims( def record_claims(
+35
View File
@@ -0,0 +1,35 @@
"""新手引导完成标记的读写(按 user_id + device_id 去重)。
登录时 [is_completed] 判断该 (账号, 设备) 是否走过引导;走完引导时 [mark_completed] 写一条
device_id 为空(老客户端 / 取不到 ANDROID_ID)一律按"未完成"处理,即照常走引导,不误跳过
"""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.models.onboarding import OnboardingCompletion
def is_completed(db: Session, *, user_id: int, device_id: str) -> bool:
"""该 (账号, 设备) 是否已完成新手引导。device_id 为空 → 一律 False。"""
if not device_id:
return False
stmt = select(OnboardingCompletion.id).where(
OnboardingCompletion.user_id == user_id,
OnboardingCompletion.device_id == device_id,
)
return db.execute(stmt).first() is not None
def mark_completed(db: Session, *, user_id: int, device_id: str) -> None:
"""走完引导时写入。device_id 为空忽略;同 (账号, 设备) 已存在则幂等(靠唯一约束兜并发)。"""
if not device_id:
return
db.add(OnboardingCompletion(user_id=user_id, device_id=device_id))
try:
db.commit()
except IntegrityError:
# 并发 / 重复提交撞唯一约束:已有行即视为成功。
db.rollback()
+8 -3
View File
@@ -46,11 +46,11 @@ class AdRewardStatusOut(BaseModel):
class EcpmReportIn(BaseModel): class EcpmReportIn(BaseModel):
"""客户端上报一次广告展示的 eCPM(内部收益统计/对账)。 """客户端上报一次广告展示的 eCPM(内部收益统计/对账)。
user_id 不在 body JWT (Bearer),防伪造ecpm 原样上报字符串,后端按/千次展示处理 user_id 不在 body JWT (Bearer),防伪造ecpm 原样上报字符串,后端按/千次展示处理(SDK getEcpm 原值,非元)
""" """
ad_type: str = Field(..., description="广告类型:reward_video(激励视频) / draw(Draw 信息流) 等") 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( ad_session_id: str | None = Field(
None, min_length=8, max_length=64, None, min_length=8, max_length=64,
description="客户端生成的一次广告会话 id;激励视频 S2S extra 会透传同值", description="客户端生成的一次广告会话 id;激励视频 S2S extra 会透传同值",
@@ -89,6 +89,11 @@ class TestGrantIn(BaseModel):
"reward_video", "reward_video",
description="模拟发奖场景:reward_video(普通激励视频) / signin_boost(签到膨胀)", 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): class TestGrantOut(BaseModel):
@@ -119,7 +124,7 @@ class FeedRewardIn(BaseModel):
ad_session_id: str | None = Field( ad_session_id: str | None = Field(
None, min_length=8, max_length=64, description="客户端生成的一次信息流广告会话 id" 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="本条广告实际展示/播放秒数") duration_seconds: int = Field(..., ge=0, description="本条广告实际展示/播放秒数")
adn: str | None = Field(None, description="实际投放 ADN") adn: str | None = Field(None, description="实际投放 ADN")
slot_id: str | None = Field(None, description="实际展示代码位") slot_id: str | None = Field(None, description="实际展示代码位")
+13
View File
@@ -41,6 +41,11 @@ class TokenPair(BaseModel):
class TokenWithUser(TokenPair): class TokenWithUser(TokenPair):
user: UserOut user: UserOut
onboarding_completed: bool = Field(
False,
description="该 设备+账号 是否已走完新手引导(据登录请求里的 device_id 计算)。"
"true → 客户端登录后直接进首页,跳过引导。",
)
# ===== 极光一键登录 ===== # ===== 极光一键登录 =====
@@ -48,6 +53,10 @@ class TokenWithUser(TokenPair):
class JverifyLoginRequest(BaseModel): class JverifyLoginRequest(BaseModel):
login_token: str = Field(..., description="客户端 loginAuth 拿到的 loginToken", min_length=1) login_token: str = Field(..., description="客户端 loginAuth 拿到的 loginToken", min_length=1)
operator: str = Field("", description="CM/CU/CT,用于日志,可选") operator: str = Field("", description="CM/CU/CT,用于日志,可选")
device_id: str = Field(
"", max_length=64,
description="硬件级设备标识(Android ANDROID_ID),用于新手引导按 设备+账号 去重;空=按未完成处理",
)
# ===== 短信验证码 ===== # ===== 短信验证码 =====
@@ -65,6 +74,10 @@ class SmsSendResponse(BaseModel):
class SmsLoginRequest(BaseModel): class SmsLoginRequest(BaseModel):
phone: str = Field(..., min_length=11, max_length=11, pattern=r"^1\d{10}$") phone: str = Field(..., min_length=11, max_length=11, pattern=r"^1\d{10}$")
code: str = Field(..., min_length=4, max_length=8) code: str = Field(..., min_length=4, max_length=8)
device_id: str = Field(
"", max_length=64,
description="硬件级设备标识(Android ANDROID_ID),用于新手引导按 设备+账号 去重;空=按未完成处理",
)
# ===== Refresh ===== # ===== Refresh =====
+6
View File
@@ -19,3 +19,9 @@ class CouponPromptShouldShowOut(BaseModel):
"""切到外卖 App 时是否还应弹领券引导窗。今天已 engage(领或拒)过 → false。""" """切到外卖 App 时是否还应弹领券引导窗。今天已 engage(领或拒)过 → false。"""
should_show: bool should_show: bool
class CouponCompletedTodayOut(BaseModel):
"""这台设备今天是否已跑完整轮领券(到 done 帧)。完成 → 首页「去领取」卡置灰。"""
completed: bool
+7
View File
@@ -16,5 +16,12 @@ class ProfileUpdateRequest(BaseModel):
return v return v
class OnboardingCompleteRequest(BaseModel):
device_id: str = Field(
..., min_length=1, max_length=64,
description="硬件级设备标识(Android ANDROID_ID),与登录时一致,用于按 设备+账号 标记引导完成",
)
class OkResponse(BaseModel): class OkResponse(BaseModel):
ok: bool = True ok: bool = True
+27 -15
View File
@@ -62,11 +62,11 @@
<div class="feat">💰 <span>省下的钱看得见,还能<b>赚金币提现</b></span></div> <div class="feat">💰 <span>省下的钱看得见,还能<b>赚金币提现</b></span></div>
</div> </div>
<button class="btn" id="dlbtn">⬇️ 下载安装包</button> <button class="btn" id="dlbtn">🏪 打开应用商店下载</button>
<div class="hint" id="hint">Android 安卓版 · 安装包约 24 MB</div> <div class="hint" id="hint">Android 安卓版 · 应用商店安全下载</div>
<div class="foot"> <div class="foot">
安装时如提示「未知来源」,请允许后继续安装<br> 将前往应用商店下载,安全放心<br>
本页为内部测试页。 本页为内部测试页。
</div> </div>
@@ -82,16 +82,18 @@
<div class="wxsteps"> <div class="wxsteps">
<div><span class="n">1</span>点右上角的 ··· 菜单</div> <div><span class="n">1</span>点右上角的 ··· 菜单</div>
<div><span class="n">2</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> </div>
<div class="closebar" id="wxclose">我知道了 ✕</div> <div class="closebar" id="wxclose">我知道了 ✕</div>
</div> </div>
<script> <script>
// APK 下载地址跟随当前页面 origin:本地测试时页面由笔记本 app-server(LAN:8770)托管 → // 应用商店跳转:用包名(App 唯一标识 = 身份证号)定位到傻瓜比价的商店下载页。
// 下载也走 LAN;生产由 app-api.shaguabijia.com 托管 → 下载走生产域名。无需按机器改 IP // 主:market:// 唤起手机自带应用市场(华为/小米/OV);兜底:应用宝网页(任何浏览器都能开)
var APK_URL = location.origin + "/media/shaguabijia.apk"; 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 ua = navigator.userAgent || "";
var isWeChat = /MicroMessenger/i.test(ua); var isWeChat = /MicroMessenger/i.test(ua);
var isIOS = /iPhone|iPad|iPod/i.test(ua); var isIOS = /iPhone|iPad|iPod/i.test(ua);
@@ -138,12 +140,20 @@
} }
return Promise.resolve(); return Promise.resolve();
} }
// 确保剪贴板写完(或最多等 400ms 兜底,防写入卡住)再触发下载。 // 跳应用商店:先尝试 market:// 唤起手机自带商店;若 2.5s 内页面没切到后台
function startDownload() { //(= 没有商店接管 market://),兜底跳应用宝网页下载页,不让用户卡死。
var fired = false; function openStore() {
function go() { if (!fired) { fired = true; window.location.href = APK_URL; } } var jumped = false;
copyInviteCode().then(go); function markJumped() { jumped = true; } // 页面切到后台 = 商店已唤起
setTimeout(go, 400); 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"); var hint = document.getElementById("hint");
@@ -160,8 +170,10 @@
document.getElementById("dlbtn").addEventListener("click", function(){ document.getElementById("dlbtn").addEventListener("click", function(){
if (isWeChat) { showMask(); return; } // 微信内:引导去浏览器 if (isWeChat) { showMask(); return; } // 微信内:引导去浏览器
if (isIOS) { alert("iOS 版即将上线,请前往 App Store 搜索「傻瓜比价」"); return; } if (isIOS) { alert("iOS 版即将上线,请前往 App Store 搜索「傻瓜比价」"); return; }
// 普通浏览器:先把邀请码写进剪贴板(写完或 400ms 兜底),再下载 APK(首启读回完成归因) // 安卓浏览器:先把邀请码写进剪贴板(legacyCopy 同步写、最可靠),再跳应用商店。
startDownload(); // 剪贴板供 App 首启归因;链路长易丢时由指纹兜底(已上报 landing-track)接住。
copyInviteCode();
openStore();
}); });
</script> </script>
</body> </body>
+1
View File
@@ -102,6 +102,7 @@
| A24 | `DELETE /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) | | 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) | | A25 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin-marquee-seeds.md) |
| A26 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./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 健康检查(无单独文档) | | - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) |
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link``sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。 > ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link``sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
+1 -1
View File
@@ -9,7 +9,7 @@
|---|---|---|---| |---|---|---|---|
| `ad_type` | str | 是 | 广告类型:`reward_video`(激励视频) / `draw`(Draw 信息流) 等 | | `ad_type` | str | 是 | 广告类型:`reward_video`(激励视频) / `draw`(Draw 信息流) 等 |
| `ad_session_id` | str\|null | 否 | 客户端生成的广告会话 ID;需和穿山甲 `extra.ad_session_id` 一致,用于 S2S 缺 eCPM 时匹配 | | `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` | | `adn` | str\|null | 否 | 实际投放 ADN(`getSdkName`),如 `pangle` |
| `slot_id` | str\|null | 否 | 实际展示代码位(底层 mediation rit,非客户端配置位) | | `slot_id` | str\|null | 否 | 实际展示代码位(底层 mediation rit,非客户端配置位) |
+1 -1
View File
@@ -12,7 +12,7 @@
|---|---|---:|---| |---|---|---:|---|
| `client_event_id` | string | 是 | 客户端生成的幂等事件 id,8-64 字符 | | `client_event_id` | string | 是 | 客户端生成的幂等事件 id,8-64 字符 |
| `ad_session_id` | string\|null | 否 | 客户端生成的一次信息流广告会话 id,用于对账/排查 | | `ad_session_id` | string\|null | 否 | 客户端生成的一次信息流广告会话 id,用于对账/排查 |
| `ecpm` | string | 是 | 本条信息流广告 eCPM,按“/千次展示”处理 | | `ecpm` | string | 是 | 本条信息流广告 eCPM(穿山甲 getEcpm 原值),按“/千次展示”处理(非元) |
| `duration_seconds` | int | 是 | 实际展示/播放秒数 | | `duration_seconds` | int | 是 | 实际展示/播放秒数 |
| `adn` | string\|null | 否 | 实际投放 ADN | | `adn` | string\|null | 否 | 实际投放 ADN |
| `slot_id` | string\|null | 否 | 实际展示代码位 | | `slot_id` | string\|null | 否 | 实际展示代码位 |
+3
View File
@@ -10,6 +10,7 @@
| 字段 | 类型 | 必填 | 默认 | 说明 | | 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---| |---|---|---|---|---|
| `reward_scene` | string | 否 | `reward_video` | 模拟发奖场景。`reward_video`=普通激励视频;`signin_boost`=签到膨胀 | | `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` 响应 `200`:`TestGrantOut`
@@ -30,4 +31,6 @@
## 说明 ## 说明
没公网、穿山甲 S2S 回调打不到本地时,debug 客户端看完广告后调它,直接走与 [ad-pangle-callback](./ad-pangle-callback.md) 相同的发奖逻辑(每次新 `trans_id`,幂等 + 每日上限/今日膨胀一次)。 没公网、穿山甲 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` 金币流水。它让已登录客户端能自助发奖 = 绕过反作弊,**严禁在生产开启**。 `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 对应的金币。
+6 -2
View File
@@ -2,7 +2,7 @@
> 跨表视角。单表字段级细节看同目录 `<表名>.md`(索引见 [README](./README.md))。 > 跨表视角。单表字段级细节看同目录 `<表名>.md`(索引见 [README](./README.md))。
> 本文专门回答三件「跨表」的事:**① 每块 App 功能用到哪些表 ② 什么操作往哪张表写 ③ 表和表怎么连(join key,含没有外键约束、靠业务字段对齐的语义关联)**。 > 本文专门回答三件「跨表」的事:**① 每块 App 功能用到哪些表 ② 什么操作往哪张表写 ③ 表和表怎么连(join key,含没有外键约束、靠业务字段对齐的语义关联)**。
> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **22 张业务表** + `alembic_version`(框架的迁移版本指针)。 > **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **23 张业务表** + `alembic_version`(框架的迁移版本指针)。
--- ---
@@ -35,6 +35,7 @@
| App 位置 / 动作 | 表 | 说明 | | App 位置 / 动作 | 表 | 说明 |
|---|---|---| |---|---|---|
| 登录(极光/短信)/ 改资料 / 注销 | [`user`](./user.md) | 登录主体,注册即登录 | | 登录(极光/短信)/ 改资料 / 注销 | [`user`](./user.md) | 登录主体,注册即登录 |
| 新手引导是否再展示 | [`onboarding_completion`](./onboarding_completion.md) | 按 设备+账号 去重;登录响应回 `onboarding_completed`,走完引导时标记,跨卸载重装 |
| 帮助与反馈 | [`feedback`](./feedback.md) | 含截图,后台人工处理 | | 帮助与反馈 | [`feedback`](./feedback.md) | 含截图,后台人工处理 |
### 运营后台 admin(独立子应用 `app/admin/`,端口 8771,独立鉴权) ### 运营后台 admin(独立子应用 `app/admin/`,端口 8771,独立鉴权)
@@ -54,7 +55,8 @@
### C 端(App 用户触发) ### C 端(App 用户触发)
| 触发(用户动作 / endpoint / 回调) | 写入 | 操作 | | 触发(用户动作 / endpoint / 回调) | 写入 | 操作 |
|---|---|---| |---|---|---|
| 登录 `POST /auth/jverify-login``/auth/sms/login` | `user` | C(首次=注册)/ U(`last_login_at`) | | 登录 `POST /auth/jverify-login``/auth/sms/login` | `user` | C(首次=注册)/ U(`last_login_at`);并**读** `onboarding_completion``onboarding_completed` |
| 走完新手引导 `POST /user/onboarding/complete` | `onboarding_completion`(C) | `(user_id, device_id)` 幂等,撞唯一约束即忽略 |
| 改昵称 `PATCH /user/profile`、传头像 `POST /user/avatar` | `user` | U | | 改昵称 `PATCH /user/profile`、传头像 `POST /user/avatar` | `user` | U |
| 注销 `DELETE /user` | `user` | U(软删:`phone→deleted_<id>``status=deleted`) | | 注销 `DELETE /user` | `user` | U(软删:`phone→deleted_<id>``status=deleted`) |
| 绑/解绑微信 `POST /wallet/bind-wechat``/unbind-wechat` | `user`.wechat_* | U | | 绑/解绑微信 `POST /wallet/bind-wechat``/unbind-wechat` | `user`.wechat_* | U |
@@ -120,6 +122,7 @@
- **`comparison_record.store_name``savings_record.shop_name`**:无 id 关联,按**店名字符串相等**给比价记录打「已下单」标记(瞬态,不写库)。两边店名同源 = 比价意图识别阶段的门店 query,语义=**店级**(同店比价多次会一并标已下单)。 - **`comparison_record.store_name``savings_record.shop_name`**:无 id 关联,按**店名字符串相等**给比价记录打「已下单」标记(瞬态,不写库)。两边店名同源 = 比价意图识别阶段的门店 query,语义=**店级**(同店比价多次会一并标已下单)。
- **广告流会话关联**:`ad_reward_record.ad_session_id` 可与 `ad_ecpm_record.ad_session_id` 对齐;`ad_watch_log` 仍是旧版兼容统计,不逐条参与发奖。 - **广告流会话关联**:`ad_reward_record.ad_session_id` 可与 `ad_ecpm_record.ad_session_id` 对齐;`ad_watch_log` 仍是旧版兼容统计,不逐条参与发奖。
- **里程碑解锁进度不存库**:`comparison_milestone_claim` 只记「哪几档已领」;进度 = `comparison_record``status='success'``count` - **里程碑解锁进度不存库**:`comparison_milestone_claim` 只记「哪几档已领」;进度 = `comparison_record``status='success'``count`
- **`onboarding_completion.(user_id, device_id)`**:`user_id` 语义关联 `user.id`(无硬 FK,同 `coupon_*` 设备表),`device_id` = 客户端硬件级 `ANDROID_ID`(≠ 领券 per-install `device_id`)。登录读、走完引导写,决定是否再展示新手引导。
### ER 关系(文字版) ### ER 关系(文字版)
``` ```
@@ -129,6 +132,7 @@ user ─1:N─ { coin_transaction, cash_transaction, withdraw_order, signin_reco
signin_boost_record, user_task, comparison_record, comparison_milestone_claim, signin_boost_record, user_task, comparison_record, comparison_milestone_claim,
savings_record, ad_reward_record, ad_watch_log, ad_ecpm_record, ad_feed_reward_record, savings_record, ad_reward_record, ad_watch_log, ad_ecpm_record, ad_feed_reward_record,
price_report, feedback } price_report, feedback }
user ─1:N─ onboarding_completion (user_id, 无硬 FK; (user_id,device_id) 去重)
comparison_record ─1:N─ price_report (comparison_record_id, 可空) comparison_record ─1:N─ price_report (comparison_record_id, 可空)
admin_user ─1:N─ admin_audit_log admin_user ─1:N─ admin_audit_log
app_config (独立, 无外键, key 为主键) app_config (独立, 无外键, key 为主键)
+3 -2
View File
@@ -3,18 +3,19 @@
> 数据库:SQLite 起步(`data/app.db`),生产可切 PostgreSQL(改 `DATABASE_URL`)。 > 数据库:SQLite 起步(`data/app.db`),生产可切 PostgreSQL(改 `DATABASE_URL`)。
> ORM:SQLAlchemy 2.0(`app/models/`),迁移:Alembic(`alembic/versions/`,`render_as_batch` 兼容 SQLite)。 > ORM:SQLAlchemy 2.0(`app/models/`),迁移:Alembic(`alembic/versions/`,`render_as_batch` 兼容 SQLite)。
> 金额字段一律存**整数**:金币=个数,现金=**分**(`*_cents`)。时间列 `DateTime(timezone=True)` > 金额字段一律存**整数**:金币=个数,现金=**分**(`*_cents`)。时间列 `DateTime(timezone=True)`
> 最后更新:2026-06-07(补全 22 张业务表 + 新增 [OVERVIEW 总览](./OVERVIEW.md)) > 最后更新:2026-06-10(新增 `onboarding_completion` 新手引导完成表 → 23 张业务表)
> 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。 > 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。
--- ---
## 表总览(22 张业务表 + `alembic_version` 框架表) ## 表总览(23 张业务表 + `alembic_version` 框架表)
### 账号 / 反馈 ### 账号 / 反馈
| 表 | 用途 | 模型 | 文档 | | 表 | 用途 | 模型 | 文档 |
|---|---|---|---| |---|---|---|---|
| `user` | 用户(登录主体);几乎所有表的外键宿主 | `models/user.py` | [详情](./user.md) | | `user` | 用户(登录主体);几乎所有表的外键宿主 | `models/user.py` | [详情](./user.md) |
| `onboarding_completion` | 新手引导完成标记(按 设备+账号 去重,登录时据此跳过引导) | `models/onboarding.py` | [详情](./onboarding_completion.md) |
| `feedback` | 用户帮助与反馈(含截图) | `models/feedback.py` | [详情](./feedback.md) | | `feedback` | 用户帮助与反馈(含截图) | `models/feedback.py` | [详情](./feedback.md) |
### 钱包 / 福利(看广告赚钱闭环) ### 钱包 / 福利(看广告赚钱闭环)
+1 -1
View File
@@ -18,7 +18,7 @@
| `ad_session_id` | String(64) | UNIQUE, index, nullable | 客户端广告会话 ID;用于普通激励视频在 S2S 缺 `ecpm` 时匹配发奖 | | `ad_session_id` | String(64) | UNIQUE, index, nullable | 客户端广告会话 ID;用于普通激励视频在 S2S 缺 `ecpm` 时匹配发奖 |
| `adn` | String(32) | nullable | 实际投放 ADN(`getShowEcpm().getSdkName()`,如 `pangle`/`gdt`) | | `adn` | String(32) | nullable | 实际投放 ADN(`getShowEcpm().getSdkName()`,如 `pangle`/`gdt`) |
| `slot_id` | String(64) | nullable | 实际展示用代码位(底层 mediation rit,非客户端配置位) | | `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`,按它做按天聚合 | | `report_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它做按天聚合 |
| `created_at` | DateTime(tz) | server_default now(), index | 时间 | | `created_at` | DateTime(tz) | server_default now(), index | 时间 |
+44
View File
@@ -0,0 +1,44 @@
# onboarding_completion — 新手引导完成标记(按 设备 + 账号 去重)
> 模型 `app/models/onboarding.py` · 仓库 `app/repositories/onboarding.py` · 迁移 `alembic/versions/onboarding_completion_table.py`(revision `onboarding_completion`) · 读 `POST /api/v1/auth/jverify-login`·`/sms/login` · 写 `POST /api/v1/user/onboarding/complete` · 测试 `tests/test_onboarding.py` · [← 索引](./README.md) · [总览](./OVERVIEW.md)
记录「**某账号在某台设备上已走完新手引导**」,一条 `(user_id, device_id)`。用途单一:决定**登录后要不要再展示新手引导教程**——本表有这条 → 跳过引导直接进首页;没有 → 走引导。
**为什么要落后端**:此前「引导只跑一次」只存客户端本地 `OnboardingPrefs`(SharedPreferences),**卸载重装即丢** → 重装会重弹引导。产品需求是「同设备 + 同账号只触发一次,且跨卸载重装」,本地存不住,故把"真相"放到服务端,按 **账号 + 硬件设备号** 去重。
**去重维度 = 设备 + 账号**(产品决议):
| 场景 | 命中本表? | 是否再展示引导 |
|---|---|---|
| 新账号(从未走过) | 否 | ✅ 展示 |
| 同账号 + **同设备**(卸载重装 / 退登重登) | 是 | ❌ 跳过 |
| 同账号 + **换新设备** | 否 | ✅ 展示(新机需重新授权悬浮窗/无障碍等) |
> ⚠️ `device_id` 用客户端**硬件级稳定标识** `Settings.Secure.ANDROID_ID`(`util/DeviceId.hardwareId()`,同签名 app 卸载重装不变、仅恢复出厂重置),**不是**领券/比价用的 per-install `DeviceId.get()`(存 SP、重装会变)。两套 device_id 不互通,别混用。
## 用在哪 / 增删改查
- **R(读)**:登录 `POST /auth/jverify-login``/auth/sms/login` —— 客户端在登录请求体里带 `device_id`,`onboarding_repo.is_completed(user_id, device_id)` 查本表有无行,结果塞进登录响应 `TokenWithUser.onboarding_completed`(true=已完成)。客户端 `AuthRepository.persist` 据此回写本地 `OnboardingPrefs`(只回写 true),既有 gating(`OnboardingHost` / `AppNavHost`)逻辑不变。
- **C(插入)**:走完引导最后一步(后台耗电屏)时,客户端 `POST /api/v1/user/onboarding/complete`(带 Bearer + `device_id`)→ `onboarding_repo.mark_completed` 插一行。**幂等**:同 `(user_id, device_id)` 已存在则撞唯一约束,`IntegrityError` 被 rollback 吞掉、视为成功;`device_id` 为空一律忽略不写。best-effort:客户端上报失败不阻断进首页(本地已先标记)。
- **U / D**:无。一台设备 + 一账号一条,完成即永久;账号注销(软删 `user`)**不清本表**(留着无害——user_id 指向的是已 `deleted` 的行;同手机号重新注册是新 user.id,自然重新走引导)。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | index, NOT NULL | 归属账号 → `user.id`(语义外键,未建 DB 级 FK,同 `coupon_*` 设备表) |
| `device_id` | String(64) | NOT NULL | 硬件级设备标识 = 客户端 `Settings.Secure.ANDROID_ID`;卸载重装不变 |
| `completed_at` | DateTime(tz) | server_default now() | 标记完成的时间 |
## 关系 / Join Key
- `user_id``user.id`(多对一:一账号在 N 台设备各一条)。**无硬 FK 约束**(同 `coupon_claim_record` / `coupon_prompt_engagement`),靠业务字段对齐。
- 判断维度 = `(user_id, device_id)` 组合等值查。
- `device_id` 与客户端 `DeviceId.hardwareId()`(ANDROID_ID)同源;**不**与领券侧 per-install `device_id` 互通。
## 索引与约束
- PK `id`;index `user_id`(`ix_onboarding_completion_user_id`);UNIQUE(`user_id`, `device_id`) = `uq_onboarding_user_device`(防同账号同设备重复写 + 兜并发 upsert)。
## 注意
- **去重维度是「设备 + 账号」**,不是纯账号:同一个人在新手机上登录会重新看引导(产品预期:新机要重新授权权限)。若日后想改「纯账号、换机也不弹」,把 `is_completed`/`mark_completed` 去掉 `device_id` 维度即可。
- `device_id` 取不到 / 为已知脏值(`9774d56d682e549c`)时客户端退回 per-install id,这类极少数设备重装会重弹一次(尽力而为)。
- 本地 `OnboardingPrefs` 现仅作**同安装快速跳过缓存**;真相以本表为准,每次登录响应同步。
- 客户端实现:`ui/onboarding/OnboardingViewModel.kt`(上报)、`data/auth/AuthRepository.kt`(读 + 同步本地)、`util/DeviceId.kt`(ANDROID_ID)。
+35
View File
@@ -0,0 +1,35 @@
@echo off
REM Local dev startup script (Windows) - mirror of run.sh
REM
REM Usage:
REM cd shaguabijia-app-server
REM run.bat
REM
REM Listens on 0.0.0.0:8770 (works for both real device via adb reverse
REM and LAN). Auto-reload on code change.
REM
REM Prerequisite (first time):
REM conda activate pricebot ^&^& pip install -e .
REM copy .env.example .env ^&^& fill JWT_SECRET_KEY
REM
REM This file is the Windows-native peer of run.sh. Keeping run.sh untouched
REM avoids CRLF/encoding fights every time someone bash-runs the .sh on Win.
cd /d "%~dp0"
if not exist .env (
echo [X] Missing .env. Run: copy .env.example .env and fill JWT_SECRET_KEY ^(plus MT_CPS_* if you test Meituan^)
exit /b 1
)
if not exist data mkdir data
REM Build/upgrade SQLite schema (idempotent; no-op if already at head)
call alembic upgrade head
if errorlevel 1 (
echo [X] alembic upgrade head failed
exit /b %errorlevel%
)
REM Long-running foreground process. Ctrl+C to stop.
uvicorn app.main:app --host 0.0.0.0 --port 8770 --reload
+67
View File
@@ -0,0 +1,67 @@
"""新手引导"按 设备+账号 只触发一次"的后端闭环测试。
覆盖:
- 全新 (账号, 设备) 登录 onboarding_completed=False(照常走引导)
- 标记完成后, (账号, 设备) 再登录 True(跳过引导,"重装"= device_id 重新登录)
- 同账号换设备(device_id 不同) False(新设备重新引导)
- 标记幂等; device_id 时按未完成处理
"""
from __future__ import annotations
def _login(client, phone: str, device_id: str | None):
body = {"phone": phone, "code": "123456"}
if device_id is not None:
body["device_id"] = device_id
r = client.post("/api/v1/auth/sms/login", json=body)
assert r.status_code == 200, r.text
return r.json()
def _mark_complete(client, access: str, device_id: str):
return client.post(
"/api/v1/user/onboarding/complete",
json={"device_id": device_id},
headers={"Authorization": f"Bearer {access}"},
)
def test_onboarding_once_per_device_and_account(client) -> None:
phone = "13800138001"
dev_a = "android-id-aaaa"
dev_b = "android-id-bbbb"
# ① 全新账号+设备:未完成
data = _login(client, phone, dev_a)
assert data["onboarding_completed"] is False
access = data["access_token"]
# ② 走完引导 → 标记完成(幂等:再标一次也 200)
assert _mark_complete(client, access, dev_a).status_code == 200
assert _mark_complete(client, access, dev_a).json()["ok"] is True
# ③ 同账号 + 同设备(模拟卸载重装后重新登录)→ 已完成,跳过引导
assert _login(client, phone, dev_a)["onboarding_completed"] is True
# ④ 同账号 + 新设备 → 仍未完成,新设备重新走引导
assert _login(client, phone, dev_b)["onboarding_completed"] is False
def test_onboarding_mark_on_one_device_does_not_leak_to_other(client) -> None:
"""B 设备标记完成,不影响 A 设备(逐设备独立)。"""
phone = "13800138002"
data_a = _login(client, phone, "devA")
assert data_a["onboarding_completed"] is False
data_b = _login(client, phone, "devB")
assert _mark_complete(client, data_b["access_token"], "devB").status_code == 200
assert _login(client, phone, "devB")["onboarding_completed"] is True
assert _login(client, phone, "devA")["onboarding_completed"] is False
def test_onboarding_missing_device_id_treated_as_incomplete(client) -> None:
"""老客户端不传 device_id:按未完成处理,不报错、不误跳过。"""
phone = "13800138003"
data = _login(client, phone, device_id=None)
assert data["onboarding_completed"] is False