diff --git a/alembic/versions/ad_reward_boost_round_id.py b/alembic/versions/ad_reward_boost_round_id.py new file mode 100644 index 0000000..d63afa6 --- /dev/null +++ b/alembic/versions/ad_reward_boost_round_id.py @@ -0,0 +1,47 @@ +"""ad_reward_record.boost_round_id(金币膨胀「本轮累计」标签) + +看完一条激励视频后客户端要弹「本轮累计获得 N 金币」,N 必须等于这一轮实际到账之和(否则用户 +会认为少发了钱)。单条到账额 reward-result 已经能给,但「一轮」的边界只有客户端知道(点「放弃 +赚钱」才算结束),客户端自己累加又会在进程被杀后丢失。 + +解法:客户端把轮次 id 随 mediaExtra 透传,穿山甲 S2S 原样带回,发奖时打在记录上; +reward-result 按 (user_id, boost_round_id) 对 granted 记录求和返回 round_coin。 + +本列是**纯标签**:不参与发奖判定,发多少/发不发完全不受影响。客户端就算一直复用同一个 id, +也只是把展示数字滚大,不产生任何新入账(求和的是已发生的发奖记录),无资损风险。 + +Revision ID: ad_reward_boost_round_id +Revises: comparison_llm_cost +Create Date: 2026-07-20 +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "ad_reward_boost_round_id" +down_revision: str | Sequence[str] | None = "comparison_llm_cost" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # 可空、无默认:SQLite 原生支持 ADD COLUMN,不需要 batch_alter_table(同 comparison_llm_cost)。 + # 存量行留 NULL = 「不属于任何一轮」,求和时天然不参与,老客户端行为不变。 + op.add_column( + "ad_reward_record", + sa.Column("boost_round_id", sa.String(length=64), nullable=True), + ) + # 求和恒带 user_id(轮 id 是客户端生成的,不能跨用户信任),故建复合索引而非单列 + op.create_index( + "ix_ad_reward_user_boost_round", + "ad_reward_record", + ["user_id", "boost_round_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("ix_ad_reward_user_boost_round", table_name="ad_reward_record") + op.drop_column("ad_reward_record", "boost_round_id") diff --git a/alembic/versions/analytics_active_idx_active_composite_index.py b/alembic/versions/analytics_active_idx_active_composite_index.py index 8cdaa5c..9a2b75a 100644 --- a/alembic/versions/analytics_active_idx_active_composite_index.py +++ b/alembic/versions/analytics_active_idx_active_composite_index.py @@ -5,8 +5,10 @@ Revises: 135e79414fd0 Create Date: 2026-07-18 17:35:00.000000 给 analytics_event 加活跃口径热点复合索引 (event, page, user_id, created_at): -activity.active_event_condition 按 (event=show & page=home) ∪ 比价 ∪ 领券 过滤后 -group by user_id、max(created_at)。覆盖索引让该聚合走 index-only,避免高频 show 事件全表扫。 +activity.active_event_condition 按 event IN (home_visible ∪ 比价 ∪ 领券) 过滤后 +group by user_id、max(created_at)。覆盖索引让该聚合走 index-only,避免高频活跃事件全表扫。 +(历史:早期首页可见用 event=show+page=home 组合,故索引含 page 列;现改单一 home_visible、 +不再按 page 过滤 → page 列成冗余,索引仍靠 event 前缀生效;如需更优可后续新迁移瘦成 (event,user_id,created_at)。) ⚠️ 本分支迁移树有**既有多头**:135e79414fd0(不活跃两表)与 phone_rebind_log 同从 comparison_llm_cost 分叉,`alembic upgrade head` 会多头报错。本迁移挂在 135e79414fd0 diff --git a/alembic/versions/drop_signin_boost_record.py b/alembic/versions/drop_signin_boost_record.py new file mode 100644 index 0000000..0447661 --- /dev/null +++ b/alembic/versions/drop_signin_boost_record.py @@ -0,0 +1,56 @@ +"""下线签到膨胀:drop signin_boost_record + +产品 2026-07 确认「固定 3000 金币的签到膨胀」从来不是设计内的口径 —— 奖励只有「签到」和 +「看视频」两种。签到弹窗里的「看广告膨胀」改与福利页看视频走同一条 reward_video 路径 +(按 eCPM 公式发,记在 ad_reward_record),signin_boost 场景整体摘除。 + +⚠️ **只 drop 这张表,不动 coin_transaction**:`biz_type='signin_boost'` 的金币流水是真发过的 +钱,账必须留得住(admin 大盘的 signin_boost_coin_total / signin_boost_watch_count 改为从 +coin_transaction 统计,继续能查回历史)。本表只是「哪天膨胀过」的业务留痕,金额与去向都能 +从流水还原,drop 掉不影响对账。 + +downgrade 只重建空表结构,**不恢复数据** —— 真要回滚得先从备份捞行。 + +Revision ID: drop_signin_boost_record +Revises: ad_reward_boost_round_id +Create Date: 2026-07-20 +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "drop_signin_boost_record" +down_revision: str | Sequence[str] | None = "ad_reward_boost_round_id" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + with op.batch_alter_table("signin_boost_record", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_signin_boost_record_user_id")) + op.drop_table("signin_boost_record") + + +def downgrade() -> None: + # 只还结构不还数据(见模块 docstring) + op.create_table( + "signin_boost_record", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("signin_date", sa.Date(), nullable=False), + sa.Column("coin_awarded", sa.Integer(), nullable=False), + sa.Column("ad_ref_id", sa.String(length=64), nullable=True), + sa.Column( + "created_at", sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False, + ), + sa.ForeignKeyConstraint(["user_id"], ["user.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", "signin_date", name="uq_signin_boost_user_date"), + ) + with op.batch_alter_table("signin_boost_record", schema=None) as batch_op: + batch_op.create_index( + batch_op.f("ix_signin_boost_record_user_id"), ["user_id"], unique=False + ) diff --git a/alembic/versions/merge_signin_boost_main.py b/alembic/versions/merge_signin_boost_main.py new file mode 100644 index 0000000..8838a4e --- /dev/null +++ b/alembic/versions/merge_signin_boost_main.py @@ -0,0 +1,32 @@ +"""合并两个 alembic head:drop_signin_boost_record(本分支)+ merge_active_phone(main)。 + +两条线同从 comparison_llm_cost 分叉——本分支的 ad_reward_boost_round_id → drop_signin_boost_record +走「金币膨胀本轮累计 + 下线签到膨胀」;main 侧的 phone_rebind_log / analytics_active_idx 两支已由 +merge_active_phone 收敛。88f2380 把 main 合进本分支后,两条迁移线在 git 上汇合了、在 alembic 图上 +却没有,于是 `alembic upgrade head`(单数)报 "Multiple head revisions are present"——按 CLAUDE.md +run.sh 启动即自动迁移,app server 会直接起不来。 + +本迁移仅把二者收敛成单 head;**不含任何表结构 / 数据改动**(纯 merge)。 + +Revision ID: merge_signin_boost_main +Revises: drop_signin_boost_record, merge_active_phone +Create Date: 2026-07-21 00:00:00.000000 +""" + +from collections.abc import Sequence + +revision: str = "merge_signin_boost_main" +down_revision: str | Sequence[str] | None = ( + "drop_signin_boost_record", + "merge_active_phone", +) +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """纯合并 head,无 schema 改动。""" + + +def downgrade() -> None: + """拆回两个 head,无 schema 改动。""" diff --git a/app/admin/main.py b/app/admin/main.py index 7345803..c869487 100644 --- a/app/admin/main.py +++ b/app/admin/main.py @@ -30,6 +30,7 @@ from app.admin.routers.analytics_health import router as analytics_health_router from app.admin.routers.event_logs import router as event_logs_router from app.admin.routers.feedback import router as feedback_router from app.admin.routers.feedback_qr import router as feedback_qr_router +from app.admin.routers.huawei_review import router as huawei_review_router from app.admin.routers.onboarding import router as onboarding_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 @@ -109,4 +110,5 @@ admin_app.include_router(cps_router) admin_app.include_router(coupon_data_router) admin_app.include_router(ad_audit_router) admin_app.include_router(ad_config_router) +admin_app.include_router(huawei_review_router) admin_app.include_router(ad_revenue_router) diff --git a/app/admin/permissions.py b/app/admin/permissions.py index ca0ee33..c0b4829 100644 --- a/app/admin/permissions.py +++ b/app/admin/permissions.py @@ -31,6 +31,7 @@ PERMISSION_CATALOG: list[dict] = [ {"group": "数据配置", "pages": [ {"key": "config", "label": "系统配置"}, {"key": "ad-revenue", "label": "广告配置"}, + {"key": "huawei-review", "label": "华为审核开关"}, {"key": "users", "label": "用户管理"}, ]}, {"group": "其他", "pages": [ @@ -51,13 +52,14 @@ BUILTIN_ROLES: list[dict] = [ {"name": SUPER_ADMIN_ROLE, "label": "管理员", "pages": []}, {"name": "operator", "label": "运营", "pages": [ "dashboard", "coupon-data", "ad-revenue-report", "comparison-records", - "cps", "device-liveness", "price-reports", "feedbacks", + "cps", "device-liveness", "price-reports", "feedbacks", "huawei-review", ]}, {"name": "finance", "label": "财务", "pages": [ "dashboard", "ad-revenue-report", "cps", "withdraws", ]}, {"name": "tech", "label": "技术", "pages": [ - "dashboard", "device-liveness", "config", "ad-revenue", "event-logs", "audit-logs", + "dashboard", "device-liveness", "config", "ad-revenue", "huawei-review", + "event-logs", "audit-logs", ]}, ] diff --git a/app/admin/repositories/ad_revenue.py b/app/admin/repositories/ad_revenue.py index b1e2162..6d39879 100644 --- a/app/admin/repositories/ad_revenue.py +++ b/app/admin/repositories/ad_revenue.py @@ -33,7 +33,29 @@ from app.admin.repositories import stats as admin_stats from app.core import rewards from app.models.ad_ecpm import AdEcpmRecord from app.models.user import User -from app.repositories import ad_pangle_revenue +from app.repositories import ad_pangle_revenue, app_config + +# 已上线过的正式业务代码位要永久保留,避免运营切换当前配置后,历史报表把旧业务位误判成测试流量。 +_KNOWN_PROD_BUSINESS_CODE_IDS = frozenset({"104098712", "104099389"}) + +# 测试应用中实际承载业务链路的代码位。广告测试 demo 的插屏/半屏/信息流测试位不在这里, +# 避免“业务口径”把开发诊断曝光混进客户端与穿山甲对账。 +_TEST_BUSINESS_CODE_IDS = frozenset({"104127529", "104127626", "104137445"}) + + +def _business_code_ids(db: Session, app_env: str | None) -> set[str]: + """返回指定应用环境下可用于业务收益对账的 GroMore 聚合代码位。""" + prod_config = app_config.get_ad_config(db) + prod_ids = set(_KNOWN_PROD_BUSINESS_CODE_IDS) | { + str(prod_config.get(key) or "").strip() + for key in ("reward_code_id", "compare_draw_code_id", "coupon_draw_code_id") + } + prod_ids.discard("") + if app_env == "prod": + return prod_ids + if app_env == "test": + return set(_TEST_BUSINESS_CODE_IDS) + return prod_ids | set(_TEST_BUSINESS_CODE_IDS) def _cn_hour(dt: datetime) -> int: @@ -82,6 +104,7 @@ def ad_revenue_report( ad_type: str | None = None, feed_scene: str | None = None, app_env: str | None = None, + revenue_scope: str = "all", granularity: str = "day", limit: int = 500, offset: int = 0, @@ -277,14 +300,18 @@ def ad_revenue_report( if feed_scene is not None: events = [e for e in events if e.get("feed_scene") == feed_scene] - # app_env 过滤(2026-06-29 新增能力,修隐患:测试应用上报的假 eCPM 如 ¥678 CPM 会污染正式收益合计/平均): - # 显式传 "prod"/"test" 只看该环境;不传=全部(维持现状)。**不擅自把默认改成排除 test**——本地 dev 库多为 - # test 数据、默认排除会使本地报表空,且「正式报表是否含 test」属产品口径。建议前端报表页加 app_env 筛选器 - # (默认选 prod),或产品确认后再把默认改成排除 test。注:穿山甲后台收益列(total_pangle_*)暂未联动此过滤 - # (它是独立对照列,且 pangle 的 test 是真实小额、非客户端那种假值)。 + # app_env 过滤:显式传 "prod"/"test" 只看该环境;不传=全部。该参数也会传给下方穿山甲聚合, + # 保证客户端预估与 GroMore 汇总使用同一应用环境口径。 if app_env is not None: events = [e for e in events if e.get("app_env") == app_env] + # 业务口径仅保留正式配置/测试业务链路实际使用的代码位。穿山甲“全量”还包含广告测试 + # demo、插屏等没有客户端收益上报的曝光,两边直接比较会天然产生假差额。 + business_code_ids: set[str] | None = None + if revenue_scope == "business": + business_code_ids = _business_code_ids(db, app_env) + events = [e for e in events if e.get("our_code_id") in business_code_ids] + # 排序:time=按时间倒序(新→旧);ecpm=按 eCPM 数值倒序(eCPM 原值是字符串「分」,转数值排; # 纯发奖行用其发奖采用的 eCPM,缺失/非法计 0 排末尾)。 if sort == "ecpm": @@ -336,7 +363,13 @@ def ad_revenue_report( total_pangle_revenue_yuan: float | None = None total_pangle_api_revenue_yuan: float | None = None if pangle_filterable: - pangle_aggs = ad_pangle_revenue.aggregate_by_date(db, date_from=date_from, date_to=date_to) + pangle_aggs = ad_pangle_revenue.aggregate_by_date( + db, + date_from=date_from, + date_to=date_to, + app_env=app_env, + our_code_ids=business_code_ids, + ) if pangle_aggs: by_date = {a["date"]: a for a in pangle_aggs} for d in daily: diff --git a/app/admin/repositories/coupon_data.py b/app/admin/repositories/coupon_data.py index 0450404..422136e 100644 --- a/app/admin/repositories/coupon_data.py +++ b/app/admin/repositories/coupon_data.py @@ -21,6 +21,9 @@ from app.models.user import User from app.repositories import ad_ecpm as crud_ecpm from app.repositories.coupon_state import DEFAULT_PLATFORMS, coupon_id_to_platform +_SLOT_OK = ("success", "already_claimed") +_SLOT_TRIED = ("success", "already_claimed", "failed") + def _cn_hour(dt: datetime) -> int: """started_at(UTC 口径)→ 北京时间小时(0–23)。naive 当 UTC(sqlite),tz-aware 直接换算(pg)。""" @@ -86,7 +89,13 @@ def _success_rates(rows: list) -> dict: } -def _session_to_row(r, phone: str | None = None, nickname: str | None = None, ad_revenue_yuan: float = 0.0) -> dict: +def _session_to_row( + r, + phone: str | None = None, + nickname: str | None = None, + ad_revenue_yuan: float = 0.0, + point_stats: dict | None = None, +) -> dict: """CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。""" return { "id": r.id, @@ -104,11 +113,60 @@ def _session_to_row(r, phone: str | None = None, nickname: str | None = None, ad "app_env": r.app_env, "started_at": r.started_at, "claimed_count": r.claimed_count, + "point_success_count": point_stats["succeeded"] if point_stats else None, + "point_total_count": point_stats["tried"] if point_stats else None, "trace_url": r.trace_url, "ad_revenue_yuan": ad_revenue_yuan, } +def _point_scores_by_trace(db: Session, trace_ids: list[str]) -> dict[str, dict[str, int]]: + """聚合查询批量返回逐场点位分数,不加载逐券明细。""" + if not trace_ids: + return {} + succeeded = func.sum(case((CouponClaimRecord.status.in_(_SLOT_OK), 1), else_=0)) + rows = db.execute( + select( + CouponClaimRecord.trace_id, + succeeded.label("succeeded"), + func.count().label("tried"), + ) + .where( + CouponClaimRecord.trace_id.in_(trace_ids), + CouponClaimRecord.status.in_(_SLOT_TRIED), + ) + .group_by(CouponClaimRecord.trace_id) + ).all() + return { + trace_id: {"succeeded": int(success_count or 0), "tried": int(tried or 0)} + for trace_id, success_count, tried in rows + if trace_id is not None + } + + +def coupon_point_details(db: Session, *, trace_id: str) -> list[dict]: + """按单个 trace 查询逐券结果;仅在后台用户点击分数时调用。""" + rows = db.execute( + select( + CouponClaimRecord.coupon_id, + CouponClaimRecord.coupon_name, + CouponClaimRecord.status, + CouponClaimRecord.reason, + ) + .where(CouponClaimRecord.trace_id == trace_id) + .order_by(CouponClaimRecord.id) + ).all() + return [ + { + "coupon_id": coupon_id, + "coupon_name": coupon_name, + "status": status, + "reason": reason, + } + for coupon_id, coupon_name, status, reason in rows + ] + + def _empty_result() -> dict: return { "summary": { @@ -249,10 +307,17 @@ def coupon_data_report( ).all() } rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in page]) + point_stats_map = _point_scores_by_trace(db, [r.trace_id for r in page]) items = [] for r in page: phone, nickname = user_map.get(r.user_id, (None, None)) if r.user_id is not None else (None, None) - items.append(_session_to_row(r, phone, nickname, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0))) + items.append(_session_to_row( + r, + phone, + nickname, + ad_revenue_yuan=rev_map.get(r.trace_id, 0.0), + point_stats=point_stats_map.get(r.trace_id), + )) return { "summary": summary, @@ -276,15 +341,17 @@ def coupon_user_records(db: Session, *, user_id: int, limit: int = 100) -> dict: ).scalar_one() rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in rows]) return { - "items": [_session_to_row(r, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0)) for r in rows], + "items": [ + _session_to_row( + r, + ad_revenue_yuan=rev_map.get(r.trace_id, 0.0), + ) + for r in rows + ], "total": int(total), } -_SLOT_OK = ("success", "already_claimed") -_SLOT_TRIED = ("success", "already_claimed", "failed") - - def coupon_slot_report( db: Session, *, date_from: str, date_to: str, app_env: str | None = None ) -> dict: diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index c9e0b74..26a36db 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -5,7 +5,7 @@ """ from __future__ import annotations -from datetime import datetime, timedelta, timezone +from datetime import date, datetime, time, timedelta, timezone from zoneinfo import ZoneInfo from sqlalchemy import Select, asc, case, desc, func, or_, select @@ -145,7 +145,7 @@ def list_users( 代价是翻页期间数据变动可能错位一条——admin 低频场景可接受(同 [list_all_withdraw_orders])。 日期入参统一转 tz-aware UTC 比较(列为 timestamptz,见 _as_utc)。""" # 最近活跃 = max(注册时间, 最近行为事件, 最近领券发起)。baseline 由 last_login_at 改为 created_at - #(登录不代表在用 App;口径统一到 activity.py,含 home_view + 比价 + 领券,见 activity.ACTIVE_EVENTS)。 + #(登录不代表在用 App;口径统一到 activity.py,含 home_visible + 比价 + 领券,见 activity.ACTIVE_EVENTS)。 # 未命中侧 coalesce 到 created_at(恒非空基线)。派生表 1:1,outerjoin 不放大行数。 ev_agg, eng_agg = activity.last_active_subqueries(db) last_active = activity.last_active_expr( @@ -209,6 +209,45 @@ def _attach_user_info(db: Session, records: list[ComparisonRecord | Feedback | P r.nickname = nick +def _comparison_conditions( + *, + user_id: int | None = None, + phone: str | None = None, + status: str | None = None, + business_type: str | None = None, + store: str | None = None, + product: str | None = None, + date_from: date | None = None, + date_to: date | None = None, +) -> list: + """比价列表与概览共用筛选条件;日期按北京自然日闭区间解释。""" + conditions = [] + if user_id is not None: + conditions.append(ComparisonRecord.user_id == user_id) + if phone: + conditions.append( + ComparisonRecord.user_id.in_( + select(User.id).where(User.phone.like(f"{phone}%")) + ) + ) + if status: + conditions.append(ComparisonRecord.status == status) + if business_type: + conditions.append(ComparisonRecord.business_type == business_type) + if store: + conditions.append(ComparisonRecord.store_name.like(f"%{store}%")) + if product: + conditions.append(ComparisonRecord.product_names.like(f"%{product}%")) + beijing = ZoneInfo("Asia/Shanghai") + if date_from is not None: + start_utc = datetime.combine(date_from, time.min, tzinfo=beijing).astimezone(timezone.utc) + conditions.append(ComparisonRecord.created_at >= start_utc) + if date_to is not None: + end_utc = datetime.combine(date_to + timedelta(days=1), time.min, tzinfo=beijing).astimezone(timezone.utc) + conditions.append(ComparisonRecord.created_at < end_utc) + return conditions + + def list_comparison_records( db: Session, *, @@ -218,30 +257,19 @@ def list_comparison_records( business_type: str | None = None, store: str | None = None, product: str | None = None, + date_from: date | None = None, + date_to: date | None = None, limit: int = 20, cursor: int | None = None, ) -> tuple[list[ComparisonRecord], int | None, int]: """admin 比价记录列表(debug)。按 user_id 精确 或 phone 前缀定位用户 + status/业务类型筛, store(店名)/product(商品名)子串模糊匹配,offset 分页(创建时间倒序、id 兜底)。 join User 取 phone/nickname 瞬态挂记录上。""" - stmt = select(ComparisonRecord) - if user_id is not None: - stmt = stmt.where(ComparisonRecord.user_id == user_id) - if phone: - stmt = stmt.where( - ComparisonRecord.user_id.in_( - select(User.id).where(User.phone.like(f"{phone}%")) - ) - ) - if status: - stmt = stmt.where(ComparisonRecord.status == status) - if business_type: - stmt = stmt.where(ComparisonRecord.business_type == business_type) - if store: - stmt = stmt.where(ComparisonRecord.store_name.like(f"%{store}%")) - if product: - # 商品名搜 product_names 派生文本列(非 items JSON:SQLite 下 JSON 中文被转义无法直接 LIKE)。 - stmt = stmt.where(ComparisonRecord.product_names.like(f"%{product}%")) + conditions = _comparison_conditions( + user_id=user_id, phone=phone, status=status, business_type=business_type, + store=store, product=product, date_from=date_from, date_to=date_to, + ) + stmt = select(ComparisonRecord).where(*conditions) items, next_cursor, total = offset_paginate( db, stmt, (desc(ComparisonRecord.created_at), desc(ComparisonRecord.id)), @@ -256,6 +284,92 @@ def list_comparison_records( return items, next_cursor, total +def _comparison_percentile(sorted_values: list[int], q: float) -> int | None: + """线性插值分位数(非负毫秒值四舍五入;单条数据返回自身)。""" + if not sorted_values: + return None + if len(sorted_values) == 1: + return sorted_values[0] + index = (len(sorted_values) - 1) * q + lower = int(index) + upper = min(lower + 1, len(sorted_values) - 1) + value = sorted_values[lower] * (upper - index) + sorted_values[upper] * (index - lower) + return int(value + 0.5) + + +def comparison_records_summary( + db: Session, + *, + user_id: int | None = None, + phone: str | None = None, + status: str | None = None, + business_type: str | None = None, + store: str | None = None, + product: str | None = None, + date_from: date | None = None, + date_to: date | None = None, +) -> dict: + """比价记录页概览聚合;主耗时均值及分位数只取成功记录。""" + conditions = _comparison_conditions( + user_id=user_id, phone=phone, status=status, business_type=business_type, + store=store, product=product, date_from=date_from, date_to=date_to, + ) + row = db.execute( + select( + func.count(ComparisonRecord.id), + func.sum(case((ComparisonRecord.status.in_(("success", "failed")), 1), else_=0)), + func.sum(case((ComparisonRecord.status == "success", 1), else_=0)), + func.avg(ComparisonRecord.llm_cost_yuan), + func.sum(case(( + (ComparisonRecord.status == "success") + & (ComparisonRecord.saved_amount_cents > 0), 1 + ), else_=0)), + func.sum(case((ComparisonRecord.status == "cancelled", 1), else_=0)), + ).where(*conditions) + ).one() + started = int(row[0] or 0) + completed = int(row[1] or 0) + success = int(row[2] or 0) + lower_price = int(row[4] or 0) + cancelled = int(row[5] or 0) + success_durations = sorted(db.execute( + select(ComparisonRecord.total_ms).where( + *conditions, + ComparisonRecord.status == "success", + ComparisonRecord.total_ms.is_not(None), + ) + ).scalars().all()) + cancelled_durations = sorted(db.execute( + select(ComparisonRecord.total_ms).where( + *conditions, + ComparisonRecord.status == "cancelled", + ComparisonRecord.total_ms.is_not(None), + ) + ).scalars().all()) + success_rate_denominator = started - cancelled + return { + "started": started, + "completed": completed, + "success": success, + "success_rate": success / success_rate_denominator if success_rate_denominator else None, + "avg_token_cost": float(row[3]) if row[3] is not None else None, + "lower_price_rate": lower_price / success if success else None, + "avg_duration_ms": ( + int(sum(success_durations) / len(success_durations) + 0.5) + if success_durations else None + ), + "p5_duration_ms": _comparison_percentile(success_durations, 0.05), + "p50_duration_ms": _comparison_percentile(success_durations, 0.5), + "p95_duration_ms": _comparison_percentile(success_durations, 0.95), + "p99_duration_ms": _comparison_percentile(success_durations, 0.99), + "cancelled": cancelled, + "cancelled_rate": cancelled / started if started else None, + "cancelled_p5_ms": _comparison_percentile(cancelled_durations, 0.05), + "cancelled_p50_ms": _comparison_percentile(cancelled_durations, 0.5), + "cancelled_p95_ms": _comparison_percentile(cancelled_durations, 0.95), + } + + def get_comparison_record(db: Session, record_id: int) -> ComparisonRecord | None: """admin 取单条比价记录(任意用户,不限本人;附 phone/nickname 瞬态)。""" rec = db.get(ComparisonRecord, record_id) diff --git a/app/admin/repositories/stats.py b/app/admin/repositories/stats.py index 260c159..e82f5b6 100644 --- a/app/admin/repositories/stats.py +++ b/app/admin/repositories/stats.py @@ -6,10 +6,10 @@ user.last_login_at / comparison_record.status / withdraw_order.status)要加索 from __future__ import annotations from collections import Counter -from datetime import date, datetime, time, timedelta, timezone -from decimal import Decimal, InvalidOperation +from datetime import UTC, date, datetime, time, timedelta, timezone +from decimal import ROUND_HALF_UP, Decimal, InvalidOperation -from sqlalchemy import case, func, select +from sqlalchemy import case, func, or_, select from sqlalchemy.orm import Session from app.admin.repositories.coupon_data import _percentile @@ -25,7 +25,7 @@ from app.models.coupon_state import ( from app.models.cps_order import CpsOrder from app.models.feedback import Feedback from app.models.savings import SavingsRecord -from app.models.signin import SigninBoostRecord, SigninRecord +from app.models.signin import SigninRecord from app.models.user import User from app.models.wallet import CoinTransaction, WithdrawOrder @@ -37,14 +37,13 @@ REWARD_VIDEO_BIZ_TYPES = ("reward_video", "ad_reward") # ad_reward 是激励视频,单独成桶、不再混进领券奖励(历史误并会把激励视频金币双计进领券)。 COUPON_REWARD_BIZ_TYPES = ("coupon", "coupon_reward") COMPARISON_REWARD_BIZ_TYPES = ("comparison", "compare_reward", "comparison_reward") -EXCLUDED_REWARD_BIZ_TYPES = ("invite_inviter", "invite_invitee", "admin_grant") -UNCLASSIFIED_FEED_BIZ_TYPES = ("feed_ad_reward",) -REGULAR_TASK_EXCLUDED_BIZ_TYPES = ( - *REWARD_VIDEO_BIZ_TYPES, - *COUPON_REWARD_BIZ_TYPES, - *COMPARISON_REWARD_BIZ_TYPES, - *EXCLUDED_REWARD_BIZ_TYPES, - *UNCLASSIFIED_FEED_BIZ_TYPES, +# 常规任务必须按明确来源相加;不能从全部正向流水反减排除项,否则新增广告/运营 +# biz_type 时会在排除清单更新前自动混入该桶。task_ 前缀在查询处单独覆盖现有及未来任务。 +REGULAR_TASK_EXACT_BIZ_TYPES = ( + "signin", + "signin_boost", + "price_report_reward", + "feedback_reward", ) MEITUAN_CPS_INVALID_STATUSES = ("4", "5") MEITUAN_CPS_SETTLED_STATUS = "6" @@ -61,7 +60,7 @@ def _beijing_today_start_utc() -> datetime: """北京时间今天 0 点对应的 UTC 时刻(DAU / 今日新增按北京时区切天)。""" now_bj = datetime.now(_BEIJING) start_bj = now_bj.replace(hour=0, minute=0, second=0, microsecond=0) - return start_bj.astimezone(timezone.utc) + return start_bj.astimezone(UTC) def today_dau(db: Session) -> int: @@ -94,8 +93,8 @@ def _period_bounds(date_from: date, date_to: date) -> tuple[datetime, datetime, """ start_bj = datetime.combine(date_from, time.min, tzinfo=_BEIJING) end_bj = datetime.combine(date_to + timedelta(days=1), time.min, tzinfo=_BEIJING) - start_utc = start_bj.astimezone(timezone.utc) - end_utc = end_bj.astimezone(timezone.utc) + start_utc = start_bj.astimezone(UTC) + end_utc = end_bj.astimezone(UTC) return ( start_utc, end_utc, @@ -109,6 +108,23 @@ def _date_range(date_from: date, date_to: date) -> list[date]: return [date_from + timedelta(days=i) for i in range(days + 1)] +def _duration_percentile(sorted_values: list[int], q: float) -> int | None: + """Linear-interpolated percentile with the same half-up rounding as Math.round.""" + if not sorted_values: + return None + if len(sorted_values) == 1: + return sorted_values[0] + index = (len(sorted_values) - 1) * q + lower = int(index) + upper = min(lower + 1, len(sorted_values) - 1) + fraction = Decimal(str(index - lower)) + value = ( + Decimal(sorted_values[lower]) * (Decimal(1) - fraction) + + Decimal(sorted_values[upper]) * fraction + ) + return int(value.quantize(Decimal("1"), rounding=ROUND_HALF_UP)) + + def _id_set(db: Session, stmt) -> set[int]: return {int(v) for v in db.execute(stmt).scalars().all() if v is not None} @@ -242,16 +258,46 @@ def dashboard_overview( ComparisonRecord.created_at >= start_local, ComparisonRecord.created_at < end_local, ) - period_comparison_total = _count(ComparisonRecord, *period_comparison_conds) - period_comparison_success = _count( - ComparisonRecord, - *period_comparison_conds, - ComparisonRecord.status == "success", + period_comparison_stats = db.execute( + select( + func.count(ComparisonRecord.id), + func.coalesce( + func.sum( + case( + (ComparisonRecord.status.in_(("success", "failed")), 1), + else_=0, + ) + ), + 0, + ), + func.coalesce( + func.sum( + case((ComparisonRecord.status == "cancelled", 1), else_=0) + ), + 0, + ), + func.coalesce( + func.sum(case((ComparisonRecord.status == "success", 1), else_=0)), + 0, + ), + func.coalesce(func.sum(ComparisonRecord.llm_cost_yuan), 0.0), + ).where(*period_comparison_conds) + ).one() + period_comparison_total = int(period_comparison_stats[0]) + period_comparison_completed = int(period_comparison_stats[1]) + period_comparison_cancelled = int(period_comparison_stats[2]) + period_comparison_success = int(period_comparison_stats[3]) + period_comparison_token_cost_yuan = float(period_comparison_stats[4]) + period_comparison_success_denominator = ( + period_comparison_total - period_comparison_cancelled ) period_comparison_success_rate = ( - round(period_comparison_success / period_comparison_total, 4) - if period_comparison_total - else 0.0 + round( + period_comparison_success / period_comparison_success_denominator, + 4, + ) + if period_comparison_success_denominator > 0 + else None ) period_saved_positive_count = _count( ComparisonRecord, @@ -282,6 +328,47 @@ def dashboard_overview( if period_avg_duration_ms is not None else None ) + completed_duration_conds = ( + *period_comparison_conds, + ComparisonRecord.status.in_(("success", "failed")), + ComparisonRecord.total_ms.is_not(None), + ) + if db.bind is not None and db.bind.dialect.name == "postgresql": + period_median_duration_ms, period_p95_duration_ms = db.execute( + select( + func.percentile_cont(0.5).within_group(ComparisonRecord.total_ms), + func.percentile_cont(0.95).within_group(ComparisonRecord.total_ms), + ).where(*completed_duration_conds) + ).one() + period_median_duration_ms = ( + int( + Decimal(str(period_median_duration_ms)).quantize( + Decimal("1"), rounding=ROUND_HALF_UP + ) + ) + if period_median_duration_ms is not None + else None + ) + period_p95_duration_ms = ( + int( + Decimal(str(period_p95_duration_ms)).quantize( + Decimal("1"), rounding=ROUND_HALF_UP + ) + ) + if period_p95_duration_ms is not None + else None + ) + else: + # SQLite 测试环境没有 percentile_cont;仅回退读取耗时单列,不加载完整记录。 + completed_durations = list( + db.execute( + select(ComparisonRecord.total_ms) + .where(*completed_duration_conds) + .order_by(ComparisonRecord.total_ms) + ).scalars() + ) + period_median_duration_ms = _duration_percentile(completed_durations, 0.5) + period_p95_duration_ms = _duration_percentile(completed_durations, 0.95) ordered_exists = ( select(SavingsRecord.id) @@ -417,7 +504,10 @@ def dashboard_overview( period_regular_task_coin_total = _sum( CoinTransaction.amount, *period_coin_conds, - CoinTransaction.biz_type.notin_(REGULAR_TASK_EXCLUDED_BIZ_TYPES), + or_( + CoinTransaction.biz_type.in_(REGULAR_TASK_EXACT_BIZ_TYPES), + CoinTransaction.biz_type.like(r"task\_%", escape="\\"), + ), ) period_cps_orders = list( db.execute( @@ -590,7 +680,14 @@ def dashboard_overview( CoinTransaction.amount > 0, CoinTransaction.biz_type == "signin_boost", ), - "signin_boost_watch_count": _count(SigninBoostRecord), + # 签到膨胀 2026-07 已下线,signin_boost_record 表随之 drop。这两项保留为**历史口径** + # (钱是真发过的,账要能查回)。次数改数金币流水:一次膨胀 = 一笔 signin_boost 流水, + # 与原来数 signin_boost_record 行数等价。 + "signin_boost_watch_count": _count( + CoinTransaction, + CoinTransaction.biz_type == "signin_boost", + CoinTransaction.amount > 0, + ), }, "cash": { "withdraw_success_cents": _sum( @@ -622,11 +719,16 @@ def dashboard_overview( }, "comparison": { "total": period_comparison_total, + "completed": period_comparison_completed, + "cancelled": period_comparison_cancelled, "success": period_comparison_success, "success_rate": period_comparison_success_rate, "ordered": period_ordered_count, "average_duration_ms": period_avg_duration_ms, + "median_duration_ms": period_median_duration_ms, + "p95_duration_ms": period_p95_duration_ms, "average_saved_cents": period_avg_saved_cents, + "token_cost_total_yuan": period_comparison_token_cost_yuan, }, "coupon": { "started": coupon_started, diff --git a/app/admin/routers/ad_revenue.py b/app/admin/routers/ad_revenue.py index 77685d4..c958e31 100644 --- a/app/admin/routers/ad_revenue.py +++ b/app/admin/routers/ad_revenue.py @@ -5,7 +5,7 @@ from __future__ import annotations from datetime import date as _date -from typing import Annotated +from typing import Annotated, Literal from fastapi import APIRouter, Depends, HTTPException, Query @@ -63,6 +63,13 @@ def get_ad_revenue_report( "建议正式收益报表选 prod,避免测试应用的假 eCPM 污染收益合计/平均" ), ] = None, + revenue_scope: Annotated[ + Literal["business", "all"], + Query( + description="business=仅业务代码位(用于客户端与穿山甲同口径对账)/ " + "all=穿山甲应用全部代码位(包含广告测试等非业务曝光)" + ), + ] = "all", granularity: Annotated[ str, Query(description="day=按天 / hour=按小时(北京时间);区间>1 天建议用 day") ] = "day", @@ -83,6 +90,7 @@ def get_ad_revenue_report( result = ad_revenue.ad_revenue_report( db, date_from=d_from.isoformat(), date_to=d_to.isoformat(), user_id=user_id, ad_type=ad_type, feed_scene=feed_scene, app_env=app_env, + revenue_scope=revenue_scope, granularity=granularity, limit=limit, offset=offset, sort=sort, ) return AdRevenueReportOut( diff --git a/app/admin/routers/comparison.py b/app/admin/routers/comparison.py index 38dc242..8876dc0 100644 --- a/app/admin/routers/comparison.py +++ b/app/admin/routers/comparison.py @@ -5,6 +5,7 @@ trace_url 无条件下发——admin 是内部 debug 工具,不走 C 端 user.de """ from __future__ import annotations +from datetime import date from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query @@ -12,7 +13,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query from app.admin.deps import AdminDb, get_current_admin from app.admin.repositories import queries from app.admin.schemas.common import CursorPage -from app.admin.schemas.comparison import AdminComparisonDetail, AdminComparisonListItem +from app.admin.schemas.comparison import ( + AdminComparisonDetail, + AdminComparisonListItem, + AdminComparisonSummary, +) router = APIRouter( prefix="/admin/api/comparison-records", @@ -34,12 +39,15 @@ def list_comparison_records( business_type: Annotated[str | None, Query()] = None, store: Annotated[str | None, Query(description="店名子串模糊匹配")] = None, product: Annotated[str | None, Query(description="商品名子串模糊匹配")] = None, + date_from: Annotated[date | None, Query(description="北京自然日起始日")] = None, + date_to: Annotated[date | None, Query(description="北京自然日结束日")] = None, limit: Annotated[int, Query(ge=1, le=100)] = 20, cursor: Annotated[int | None, Query()] = None, ) -> CursorPage[AdminComparisonListItem]: items, next_cursor, total = queries.list_comparison_records( db, user_id=user_id, phone=phone, status=status, business_type=business_type, store=store, product=product, + date_from=date_from, date_to=date_to, limit=limit, cursor=cursor, ) return CursorPage( @@ -49,6 +57,29 @@ def list_comparison_records( ) +@router.get( + "/summary", + response_model=AdminComparisonSummary, + summary="比价记录概览聚合", +) +def comparison_records_summary( + db: AdminDb, + user_id: Annotated[int | None, Query()] = None, + phone: Annotated[str | None, Query(description="手机号前缀")] = None, + status: Annotated[str | None, Query(pattern="^(success|failed|cancelled)$")] = None, + business_type: Annotated[str | None, Query()] = None, + store: Annotated[str | None, Query(description="店名子串模糊匹配")] = None, + product: Annotated[str | None, Query(description="商品名子串模糊匹配")] = None, + date_from: Annotated[date | None, Query(description="北京自然日起始日")] = None, + date_to: Annotated[date | None, Query(description="北京自然日结束日")] = None, +) -> AdminComparisonSummary: + return AdminComparisonSummary(**queries.comparison_records_summary( + db, user_id=user_id, phone=phone, status=status, + business_type=business_type, store=store, product=product, + date_from=date_from, date_to=date_to, + )) + + @router.get( "/{record_id}", response_model=AdminComparisonDetail, diff --git a/app/admin/routers/coupon_data.py b/app/admin/routers/coupon_data.py index 82a882d..89a23e2 100644 --- a/app/admin/routers/coupon_data.py +++ b/app/admin/routers/coupon_data.py @@ -18,6 +18,8 @@ from app.admin.schemas.coupon_data import ( CouponDataOut, CouponDataRow, CouponDataSummary, + CouponPointDetail, + CouponPointDetailsOut, CouponSlotRow, CouponSlotsOut, CouponUserRecordsOut, @@ -122,6 +124,22 @@ def get_coupon_slots( ) +@router.get( + "/point-details", + response_model=CouponPointDetailsOut, + summary="按 trace 查询单次领券任务的逐券点位明细", +) +def get_coupon_point_details( + db: AdminDb, + trace_id: Annotated[str, Query(min_length=1, max_length=64, description="领券 trace_id")], +) -> CouponPointDetailsOut: + items = coupon_data.coupon_point_details(db, trace_id=trace_id) + return CouponPointDetailsOut( + trace_id=trace_id, + items=[CouponPointDetail(**item) for item in items], + ) + + @router.get( "/user-records", response_model=CouponUserRecordsOut, diff --git a/app/admin/routers/huawei_review.py b/app/admin/routers/huawei_review.py new file mode 100644 index 0000000..b23fb18 --- /dev/null +++ b/app/admin/routers/huawei_review.py @@ -0,0 +1,58 @@ +"""admin 华为审核开关:控制新手引导页(快速设置)在华为 ROM 客户端能否被用户关闭。 + +存在 app_config 表的 huawei_review dict(见 repositories/app_config.get_huawei_review/set_huawei_review)。 +客户端经 /api/v1/platform/huawei-review 拉取(且只有华为 ROM 机型会去拉)。权限 operator/tech + 审计。 +""" +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy.orm import Session + +from app.admin.audit import write_audit +from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role +from app.admin.schemas.huawei_review import HuaweiReviewOut, HuaweiReviewUpdate +from app.models.admin import AdminUser +from app.models.app_config import AppConfig +from app.repositories import app_config + +router = APIRouter( + prefix="/admin/api/huawei-review", + tags=["admin-huawei-review"], + dependencies=[Depends(get_current_admin)], +) + + +def _out(db: Session) -> HuaweiReviewOut: + row = db.get(AppConfig, app_config.HUAWEI_REVIEW_KEY) + return HuaweiReviewOut( + mode=app_config.get_huawei_review(db)["mode"], + updated_at=row.updated_at.isoformat() if row is not None else None, + ) + + +@router.get("", response_model=HuaweiReviewOut, summary="华为审核开关当前状态") +def get_huawei_review(db: AdminDb) -> HuaweiReviewOut: + return _out(db) + + +@router.patch("", response_model=HuaweiReviewOut, summary="切换华为审核开关(带审计)") +def update_huawei_review( + body: HuaweiReviewUpdate, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator", "tech"))], + db: AdminDb, +) -> HuaweiReviewOut: + before = app_config.get_huawei_review(db)["mode"] + try: + app_config.set_huawei_review(db, body.mode, admin_id=admin.id, commit=False) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + write_audit( + db, admin, action="huawei_review.set", target_type="huawei_review", + target_id=app_config.HUAWEI_REVIEW_KEY, + detail={"before": before, "after": body.mode}, ip=get_client_ip(request), commit=False, + ) + db.commit() + return _out(db) diff --git a/app/admin/schemas/comparison.py b/app/admin/schemas/comparison.py index ec1d3aa..13c49ff 100644 --- a/app/admin/schemas/comparison.py +++ b/app/admin/schemas/comparison.py @@ -47,6 +47,27 @@ class AdminComparisonListItem(BaseModel): created_at: datetime +class AdminComparisonSummary(BaseModel): + """比价记录页概览;主耗时指标仅统计 status=success。""" + + started: int + completed: int + success: int + success_rate: float | None = None + avg_token_cost: float | None = None + lower_price_rate: float | None = None + avg_duration_ms: int | None = None + p5_duration_ms: int | None = None + p50_duration_ms: int | None = None + p95_duration_ms: int | None = None + p99_duration_ms: int | None = None + cancelled: int + cancelled_rate: float | None = None + cancelled_p5_ms: int | None = None + cancelled_p50_ms: int | None = None + cancelled_p95_ms: int | None = None + + class AdminComparisonDetail(AdminComparisonListItem): """详情:概要 + 全量明细(逐平台对比 / LLM 每次调用 / 原始 payload)。""" diff --git a/app/admin/schemas/coupon_data.py b/app/admin/schemas/coupon_data.py index c560397..f295650 100644 --- a/app/admin/schemas/coupon_data.py +++ b/app/admin/schemas/coupon_data.py @@ -49,6 +49,15 @@ class CouponDataHourly(BaseModel): avg_elapsed_ms: int | None = None +class CouponPointDetail(BaseModel): + """一次领券任务中的单券点位结果。""" + + coupon_id: str + coupon_name: str | None = None + status: str = Field(..., description="success / already_claimed / failed / skipped") + reason: str | None = None + + class CouponDataRow(BaseModel): """一条领券明细(一次领券任务)。""" @@ -69,6 +78,12 @@ class CouponDataRow(BaseModel): app_env: str | None = None started_at: datetime = Field(..., description="发起时刻(明细「时间」列)") claimed_count: int | None = None + point_success_count: int | None = Field( + None, description="本次成功券点位数(success+already_claimed);无逐券埋点为空" + ) + point_total_count: int | None = Field( + None, description="本次尝试券点位数(success+already_claimed+failed,不含 skipped);无逐券埋点为空" + ) trace_url: str | None = Field(None, description="pricebot 公网 trace 链接(仅 completed 有);admin 渲染可点链接,无则显示可复制 trace_id") ad_revenue_yuan: float = Field( 0.0, description="本次领券看的信息流广告预估收益(元);按 trace_id 聚合 ad_ecpm_record" @@ -89,6 +104,13 @@ class CouponDataOut(BaseModel): items: list[CouponDataRow] = Field(..., description="逐条领券明细(当前页)") +class CouponPointDetailsOut(BaseModel): + """单次领券任务的逐券点位结果,供点击分数时按需加载。""" + + trace_id: str + items: list[CouponPointDetail] = Field(default_factory=list) + + class CouponUserRecordsOut(BaseModel): """某用户全部领券记录(点手机号抽屉用):total=该用户领券总次数,items=记录列表(UserRecordsDrawer 渲染)。""" diff --git a/app/admin/schemas/dashboard.py b/app/admin/schemas/dashboard.py index b3e008a..bb8a281 100644 --- a/app/admin/schemas/dashboard.py +++ b/app/admin/schemas/dashboard.py @@ -53,11 +53,16 @@ class DashboardPeriodUsers(BaseModel): class DashboardPeriodComparison(BaseModel): total: int + completed: int + cancelled: int success: int - success_rate: float + success_rate: float | None = None ordered: int average_duration_ms: int | None = None + median_duration_ms: int | None = None + p95_duration_ms: int | None = None average_saved_cents: int | None = None + token_cost_total_yuan: float = 0.0 class DashboardPeriodCoupon(BaseModel): diff --git a/app/admin/schemas/huawei_review.py b/app/admin/schemas/huawei_review.py new file mode 100644 index 0000000..198a321 --- /dev/null +++ b/app/admin/schemas/huawei_review.py @@ -0,0 +1,19 @@ +"""admin 华为审核开关 schemas(两态:default / review)。""" +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel + + +class HuaweiReviewOut(BaseModel): + """当前开关状态。updated_at 给后台展示「谁什么时候切的」提供时间锚点。""" + + mode: Literal["default", "review"] + updated_at: str | None = None # ISO 字符串;从未切过为 None + + +class HuaweiReviewUpdate(BaseModel): + """切换开关。整值覆盖,不做部分更新(就一个字段)。""" + + mode: Literal["default", "review"] diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index 3b9cbaf..bf98e80 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -3,6 +3,8 @@ 路由前缀 `/api/v1/ad`: GET /pangle-callback 穿山甲 S2S 发奖回调(**无 JWT,靠验签**),穿山甲服务器调 GET /reward-status 客户端查今日看广告发奖进度(Bearer) + GET /reward-result/{ad_session_id} + 客户端按会话查本次广告实发金币(Bearer,只读,弹窗金额用) 发奖走服务端:激励视频播完穿山甲回调本接口,验签通过后幂等发金币。客户端只负责 看完后刷新余额,不参与发奖,被破解也刷不到钱。 @@ -13,7 +15,7 @@ import json import logging import uuid -from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi import APIRouter, Depends, HTTPException, Path, Request, status from app.api.deps import CurrentUser, DbSession from app.core import rewards @@ -25,8 +27,8 @@ from app.repositories import ad_feed_reward as crud_feed from app.repositories import ad_reward as crud_ad from app.repositories import ad_watch as crud_watch from app.repositories import app_config -from app.repositories import signin as crud_signin from app.schemas.ad import ( + AdRewardResultOut, AdRewardStatusOut, EcpmReportIn, EcpmReportOut, @@ -52,11 +54,14 @@ REASON_BAD_PARAMS = 1 # 验签过但缺 trans_id / user_id 非数字 REASON_UNKNOWN_USER = 2 # user_id 不存在(可能伪造) REWARD_SCENE_REWARD_VIDEO = "reward_video" -REWARD_SCENE_SIGNIN_BOOST = "signin_boost" # 提现看视频:看完才能提现的「硬门槛」广告,**不发金币**,只记一条幂等记录(收益由 eCPM 上报口径 # ad_type="withdrawal_video" 单独统计)。故意不放进 SUPPORTED_REWARD_SCENES——它不走发币分支。 REWARD_SCENE_WITHDRAWAL_AD = "withdrawal_ad" -SUPPORTED_REWARD_SCENES = {REWARD_SCENE_REWARD_VIDEO, REWARD_SCENE_SIGNIN_BOOST} +# 2026-07 下线 signin_boost(签到膨胀):它按固定 3000 金币发,与广告实际收益脱钩,产品确认 +# 从来不是设计内的口径。签到弹窗里的「看广告膨胀」现在与福利页看视频走同一条 reward_video +# 路径(按 eCPM 公式发),奖励只剩「签到」+「看视频」两种。历史发币流水(coin_transaction +# .biz_type='signin_boost')保留不动——钱是真发过的,账必须留。 +SUPPORTED_REWARD_SCENES = {REWARD_SCENE_REWARD_VIDEO} def _parse_extra(raw_extra: str | None) -> dict[str, str]: @@ -118,6 +123,11 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut: extra.update(_parse_extra(params.get(extra_key))) reward_scene = extra.get("reward_scene") or REWARD_SCENE_REWARD_VIDEO ad_session_id = extra.get("ad_session_id") + # 「这条广告属于哪一轮膨胀」。纯标签:不参与发奖判定,只让 reward-result 能把同一轮求和成 + # 弹窗要显示的累计值(见 crud_ad.round_coin_total)。老客户端不带 → NULL → 累计值返 null。 + boost_round_id = (extra.get("boost_round_id") or None) + if boost_round_id is not None: + boost_round_id = boost_round_id[:64] ecpm = params.get("ecpm") # 环境隔离:激励视频 mediaExtra 里带「这次观看属于哪个后端环境」(srv_env=dev/prod,客户端按 @@ -169,50 +179,11 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut: user_id, trans_id, reward_scene, ) return PangleCallbackOut(is_verify=False, reason=REASON_BAD_PARAMS) - if reward_scene == REWARD_SCENE_SIGNIN_BOOST: - try: - boost, _balance = crud_signin.boost_today_signin( - db, user_id, ad_ref_id=trans_id, commit=False - ) - except crud_signin.NotSignedTodayError: - db.rollback() - rec = crud_ad.record_external_reward( - db, user_id, trans_id, coin=0, reward_scene=reward_scene, - ad_session_id=ad_session_id, ecpm=ecpm, - reward_name=params.get("reward_name"), raw=raw[:1024], - status="not_signed", - ) - except crud_signin.AlreadyBoostedError: - db.rollback() - rec = crud_ad.record_external_reward( - db, user_id, trans_id, coin=0, reward_scene=reward_scene, - ad_session_id=ad_session_id, ecpm=ecpm, - reward_name=params.get("reward_name"), raw=raw[:1024], - status="already_boosted", - ) - except crud_signin.LastCycleDayBoostBlockedError: - db.rollback() - rec = crud_ad.record_external_reward( - db, user_id, trans_id, coin=0, reward_scene=reward_scene, - ad_session_id=ad_session_id, ecpm=ecpm, - reward_name=params.get("reward_name"), raw=raw[:1024], - status="last_day", - ) - else: - rec = crud_ad.record_external_reward( - db, user_id, trans_id, coin=boost.coin_awarded, - reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm=ecpm, - reward_name=params.get("reward_name"), raw=raw[:1024], - commit=False, - ) - db.commit() - db.refresh(rec) - else: - rec = crud_ad.grant_ad_reward( - db, user_id, trans_id, ecpm=ecpm, ad_session_id=ad_session_id, - reward_scene=REWARD_SCENE_REWARD_VIDEO, - reward_name=params.get("reward_name"), raw=raw[:1024], - ) + rec = crud_ad.grant_ad_reward( + db, user_id, trans_id, ecpm=ecpm, ad_session_id=ad_session_id, + reward_scene=REWARD_SCENE_REWARD_VIDEO, boost_round_id=boost_round_id, + reward_name=params.get("reward_name"), raw=raw[:1024], + ) except crud_ad.UnknownUserError: logger.warning("pangle callback unknown user_id=%d trans_id=%s", user_id, trans_id) return PangleCallbackOut(is_verify=False, reason=REASON_UNKNOWN_USER) @@ -242,6 +213,46 @@ def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut: ) +@router.get( + "/reward-result/{ad_session_id}", + response_model=AdRewardResultOut, + summary="按 ad_session_id 查本次广告的权威发奖结果", + dependencies=[Depends(rate_limit(120, 60, "ad-reward-result"))], +) +def reward_result( + user: CurrentUser, + db: DbSession, + ad_session_id: str = Path(..., min_length=8, max_length=64, description="本次广告会话 id"), +) -> AdRewardResultOut: + """客户端看完激励视频后轮询本接口拿**本次实发金币 + 本轮累计**用于弹窗,不再用余额差 / + coin_per_ad 估算(修「弹窗数值与真实金币对不上」)。 + + round_coin 是「恭喜累计获得奖励」弹窗真正显示的数:本轮(= 客户端的 boost_round_id)所有 + granted 记录之和。由服务端求和而不是客户端自己累加——客户端进程被杀/重建后本地累计会丢, + 发奖记录不会。取不到轮 id(pending / 老客户端 / extra 丢失)时为 null,客户端退回显示单条。 + + S2S 回调异步:查不到记录 = 回调还没到 → 返 200 + status='pending' 让客户端继续重试, + **不返 404**(404 只表示路由不存在)。纯只读:发奖仍只由验签过的 S2S 回调完成, + 这里不写库、不产生任何奖励,被刷也只是查自己的记录。 + """ + rec = crud_ad.find_by_session(db, user.id, ad_session_id) + if rec is None: + # 连记录都没有 → 不知道属于哪一轮,round_coin 一并为 null(不是 0,0 会被当成"本轮没赚到") + return AdRewardResultOut( + ad_session_id=ad_session_id, status="pending", coin=None, round_coin=None, + ) + # 本条不是 granted 时**仍返本轮累计**(这条按 0 计):第 3 条撞每日上限那下,客户端的限额 + # toast 要显示的是前两条已到账的总额,不是空。 + round_coin = ( + crud_ad.round_coin_total(db, user.id, rec.boost_round_id) + if rec.boost_round_id + else None + ) + return AdRewardResultOut( + ad_session_id=ad_session_id, status=rec.status, coin=rec.coin, round_coin=round_coin, + ) + + @router.post( "/watch-report", response_model=WatchReportOut, @@ -280,19 +291,31 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm Bearer 鉴权,user_id 取自 JWT(不信 body)。best-effort:落库即 ok,客户端 fire-and-forget, 丢一两条不影响业务(穿山甲后台报表是结算权威)。eCPM 与发奖(S2S)是两条独立流,不逐条关联。 """ + attributed_trace_id = crud_ecpm.attributable_trace_id( + db, + feed_scene=payload.feed_scene, + trace_id=payload.trace_id, + exposure_ms=payload.exposure_ms, + ) + if payload.trace_id and attributed_trace_id is None: + logger.info( + "detach late coupon ad impression from failed trace user_id=%d trace=%s session=%s", + user.id, payload.trace_id, payload.ad_session_id, + ) crud_ecpm.create_ecpm_record( db, user.id, ad_type=payload.ad_type, ecpm_raw=payload.ecpm, ad_session_id=payload.ad_session_id, adn=payload.adn, slot_id=payload.slot_id, feed_scene=payload.feed_scene, - trace_id=payload.trace_id, + trace_id=attributed_trace_id, app_env=payload.app_env, our_code_id=payload.our_code_id, + exposure_ms=payload.exposure_ms, ) logger.info( - "ad ecpm report user_id=%d type=%s scene=%s session=%s ecpm=%s adn=%s slot=%s app=%s code=%s", + "ad ecpm report user_id=%d type=%s scene=%s session=%s ecpm=%s exposure_ms=%s adn=%s slot=%s app=%s code=%s", user.id, payload.ad_type, payload.feed_scene, payload.ad_session_id, payload.ecpm, - payload.adn, payload.slot_id, payload.app_env, payload.our_code_id, + payload.exposure_ms, payload.adn, payload.slot_id, payload.app_env, payload.our_code_id, ) return EcpmReportOut(ok=True) @@ -317,55 +340,27 @@ def test_grant(user: CurrentUser, db: DbSession, payload: TestGrantIn | None = N if reward_scene not in SUPPORTED_REWARD_SCENES: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="bad reward_scene") - # 每次新 trans_id,模拟一次独立的穿山甲发奖回调(幂等键各不相同 → 每次都发,直到当日上限/今日膨胀一次) + # 每次新 trans_id,模拟一次独立的穿山甲发奖回调(幂等键各不相同 → 每次都发,直到当日上限) trans_id = f"test-{user.id}-{uuid.uuid4().hex}" - if reward_scene == REWARD_SCENE_SIGNIN_BOOST: - try: - boost, _balance = crud_signin.boost_today_signin( - db, user.id, ad_ref_id=trans_id, commit=False - ) - except crud_signin.NotSignedTodayError: - db.rollback() - rec = crud_ad.record_external_reward( - db, user.id, trans_id, coin=0, reward_scene=reward_scene, - raw="client debug test-grant signin_boost", status="not_signed", - ) - except crud_signin.AlreadyBoostedError: - db.rollback() - rec = crud_ad.record_external_reward( - db, user.id, trans_id, coin=0, reward_scene=reward_scene, - raw="client debug test-grant signin_boost", status="already_boosted", - ) - except crud_signin.LastCycleDayBoostBlockedError: - db.rollback() - rec = crud_ad.record_external_reward( - db, user.id, trans_id, coin=0, reward_scene=reward_scene, - raw="client debug test-grant signin_boost", status="last_day", - ) - else: - rec = crud_ad.record_external_reward( - db, user.id, trans_id, coin=boost.coin_awarded, - reward_scene=reward_scene, reward_name="测试签到膨胀", - raw="client debug test-grant signin_boost", commit=False, - ) - 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=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 + # 正式链路的轮次 id 走 S2S 的 mediaExtra;本接口不经 S2S,只能由 body 补,否则 debug 包 + # 的 reward-result 恒返 round_coin=null,「弹窗 40 → 60」那套累计验收在本地跑不起来。 + boost_round_id = (payload.boost_round_id if payload is not None else None) or None + # 优先用客户端按 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=ecpm_val, ad_session_id=ad_session_id, + boost_round_id=boost_round_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 (used, limit, coin_per, round_count, cooldown_until, _watched, _watch_limit) = crud_ad.today_status(db, user.id) diff --git a/app/api/v1/platform.py b/app/api/v1/platform.py index c7a5873..7ee5be4 100644 --- a/app/api/v1/platform.py +++ b/app/api/v1/platform.py @@ -20,6 +20,7 @@ from app.schemas.platform import ( AdConfigPublicOut, AppFlagsOut, AppVersionOut, + HuaweiReviewOut, PlatformStatsOut, SavingsFeedItem, SavingsFeedOut, @@ -72,6 +73,15 @@ def ad_config(db: DbSession) -> AdConfigPublicOut: ) +@router.get("/huawei-review", response_model=HuaweiReviewOut, summary="华为审核开关(不鉴权)") +def huawei_review(db: DbSession) -> HuaweiReviewOut: + """客户端进新手引导前拉一次,决定「快速设置」权限步左上角要不要给退出按钮。 + 不鉴权:引导页在登录之前就展示,此时必然没有 token。空库回退 default(=不给退出按钮,维持现状)。 + 只有华为 ROM 客户端会来拉(荣耀 MagicOS 不拉),故这里不做机型判断,由客户端自己 gate。""" + mode = app_config.get_huawei_review(db)["mode"] + return HuaweiReviewOut(mode=mode, onboarding_closable=(mode == "review")) + + @router.get("/app-version", response_model=AppVersionOut, summary="最新 App 版本(OTA 检查更新,不鉴权)") def app_version(db: DbSession) -> AppVersionOut: """客户端启动 / 手动检查更新时拉取。不鉴权:版本信息非敏感,且检查更新可能在登录前。 diff --git a/app/api/v1/signin.py b/app/api/v1/signin.py index 45f3646..02848e0 100644 --- a/app/api/v1/signin.py +++ b/app/api/v1/signin.py @@ -1,9 +1,12 @@ """签到 endpoint。 路由前缀 `/api/v1/signin`: - GET /status 今日签到状态 + 14 天档位 + GET /status 今日签到状态 + 7 天档位 POST / 执行今日签到 - POST /boost 签到后看广告膨胀金币 + +2026-07 下线 `POST /boost`(签到膨胀):它按固定 3000 金币补发、与广告实际收益脱钩。 +签到弹窗里的「看广告膨胀」改与福利页看视频走同一条 reward_video 路径(按 eCPM 发, +`/ad/pangle-callback` → `/ad/reward-result` 取金额),奖励只剩「签到」+「看视频」两种。 """ from __future__ import annotations @@ -12,15 +15,8 @@ import logging from fastapi import APIRouter, HTTPException, status from app.api.deps import CurrentUser, DbSession -from app.repositories import ad_reward as crud_ad from app.repositories import signin as crud_signin -from app.repositories import wallet as crud_wallet -from app.schemas.welfare import ( - SigninBoostRequest, - SigninBoostResultOut, - SigninResultOut, - SigninStatusOut, -) +from app.schemas.welfare import SigninResultOut, SigninStatusOut logger = logging.getLogger("shagua.signin") @@ -50,34 +46,3 @@ def do_signin(user: CurrentUser, db: DbSession) -> SigninResultOut: streak=record.streak, coin_balance=balance, ) - - -@router.post("/boost", response_model=SigninBoostResultOut, summary="签到后看广告膨胀金币") -def boost_signin( - payload: SigninBoostRequest, user: CurrentUser, db: DbSession -) -> SigninBoostResultOut: - if not payload.ad_ref_id: - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="ad reward required") - ad_rec = crud_ad.find_by_trans(db, payload.ad_ref_id) - if ( - ad_rec is None - or ad_rec.user_id != user.id - or ad_rec.reward_scene != "signin_boost" - or ad_rec.status != "granted" - ): - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="ad reward not verified") - record = crud_signin.boost_by_ad_ref(db, user.id, payload.ad_ref_id) - if record is None: - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="signin boost not granted") - acc = crud_wallet.get_or_create_account(db, user.id) - balance = acc.coin_balance - - logger.info( - "signin boost ok user_id=%d date=%s coin=%d", - user.id, record.signin_date, record.coin_awarded, - ) - return SigninBoostResultOut( - coin_awarded=record.coin_awarded, - coin_balance=balance, - signin_date=record.signin_date.isoformat(), - ) diff --git a/app/core/config_schema.py b/app/core/config_schema.py index 9edb043..a46e96f 100644 --- a/app/core/config_schema.py +++ b/app/core/config_schema.py @@ -66,11 +66,6 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = { "default": r.VIDEO_ROUND_COOLDOWN_SECONDS, "label": "广告关闭后冷却(秒)", "group": "看广告", "type": "int", "help": "点击退出广告后,下次点击观看前的冷却时间,默认 3 秒。", }, - "signin_boost_coin": { - "default": r.SIGNIN_BOOST_COIN, "label": "签到膨胀固定金币", - "group": "签到", "type": "int", - "help": "Day1-Day6 签到后看完激励视频额外发放的固定金币;Day7 不展示也不允许膨胀。", - }, "comparing_ad_enabled": { "default": True, "label": "比价/领券期信息流广告", "group": "看广告", "type": "bool", "hidden": True, diff --git a/app/core/rewards.py b/app/core/rewards.py index eccdc25..284dd97 100644 --- a/app/core/rewards.py +++ b/app/core/rewards.py @@ -239,8 +239,8 @@ def calculate_ad_reward_coin(ecpm: str | int | float | None, count_after_this: i return max(0, round(yuan * COIN_PER_YUAN)) -# 签到看广告膨胀:S2S 固定补发(原型 2026-06 由 2000 提到 3000,对应 CTA「看广告最高膨胀至3000金币」)。 -SIGNIN_BOOST_COIN: int = 3000 +# 签到膨胀(SIGNIN_BOOST_COIN,固定 3000)已于 2026-07 下线:它与广告实际收益脱钩,产品确认 +# 非设计内口径。签到弹窗的「看广告膨胀」现与福利页看视频同走 calculate_ad_reward_coin。 # ===== 看激励视频发金币(穿山甲 S2S 服务端回调发奖)===== @@ -334,5 +334,3 @@ def get_ad_cooldown_sec(db) -> int: # noqa: ANN001 return int(_cfg(db, "ad_cooldown_sec")) -def get_signin_boost_coin(db) -> int: # noqa: ANN001 - return int(_cfg(db, "signin_boost_coin")) diff --git a/app/models/__init__.py b/app/models/__init__.py index 605ddda..5ed8a6b 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -43,7 +43,7 @@ from app.models.ops_stat_config import OpsStatConfig # noqa: F401 from app.models.price_observation import PriceObservation # noqa: F401 from app.models.price_report import PriceReport # noqa: F401 from app.models.savings import SavingsRecord # noqa: F401 -from app.models.signin import SigninBoostRecord, SigninRecord # noqa: F401 +from app.models.signin import SigninRecord # noqa: F401 from app.models.store_mapping import StoreMapping # noqa: F401 from app.models.task import UserTask # noqa: F401 from app.models.user import User # noqa: F401 diff --git a/app/models/ad_reward.py b/app/models/ad_reward.py index d45be76..ac9ab06 100644 --- a/app/models/ad_reward.py +++ b/app/models/ad_reward.py @@ -8,7 +8,7 @@ from __future__ import annotations from datetime import datetime -from sqlalchemy import DateTime, ForeignKey, Integer, String, func +from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, func from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base @@ -16,6 +16,10 @@ from app.db.base import Base class AdRewardRecord(Base): __tablename__ = "ad_reward_record" + __table_args__ = ( + # 「本轮膨胀累计发了多少」= SUM(coin) WHERE user_id=? AND boost_round_id=? AND status='granted' + Index("ix_ad_reward_user_boost_round", "user_id", "boost_round_id"), + ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) # 穿山甲交易号,幂等键(同号回调不重复发奖) @@ -31,6 +35,10 @@ class AdRewardRecord(Base): reward_scene: Mapped[str] = mapped_column(String(32), nullable=False, default="reward_video") # 客户端生成并通过 extra 透传的广告会话 id ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + # 客户端生成并通过 extra 透传的「膨胀轮」id:一轮 = 用户点「去膨胀」到点「放弃赚钱」之间连看的 + # 若干条广告。纯标签,不影响发多少/发不发,只用于把同一轮的发奖记录求和成弹窗要显示的累计值。 + # 轮次边界完全由客户端定(它才知道用户点了放弃);老客户端/extra 丢失时为 NULL → 累计值返 null。 + boost_round_id: Mapped[str | None] = mapped_column(String(64), nullable=True) # 本次发奖采用的 eCPM 原始值(回调自带或按 ad_session_id 匹配的客户端上报) ecpm_raw: Mapped[str | None] = mapped_column(String(32), nullable=True) # 来源(广告收益报表用):我们的应用环境 prod/test + 我们配置的代码位 104xxx。 diff --git a/app/models/analytics_event.py b/app/models/analytics_event.py index 21f03c5..394f4ff 100644 --- a/app/models/analytics_event.py +++ b/app/models/analytics_event.py @@ -25,8 +25,9 @@ class AnalyticsEvent(Base): __tablename__ = "analytics_event" __table_args__ = ( # 活跃口径聚合热点(activity.active_event_condition + last_active_subqueries): - # 按 (event,page) 过滤 首页可见(show/home)∪比价∪领券,再 group by user_id 取 - # max(created_at)。覆盖索引 → 该聚合走 index-only,避免高频 show 事件全表扫。 + # 按 event IN (home_visible∪比价∪领券) 过滤,再 group by user_id 取 max(created_at)。 + # 覆盖索引 → 该聚合走 index-only。注:page 列是早期 show+home 组合的遗留,现不再按 page + # 过滤(索引靠 event 前缀仍生效);后续可新迁移瘦成 (event,user_id,created_at)。 Index("ix_analytics_event_active", "event", "page", "user_id", "created_at"), ) diff --git a/app/models/signin.py b/app/models/signin.py index 0a94ea0..1fadf2c 100644 --- a/app/models/signin.py +++ b/app/models/signin.py @@ -1,6 +1,10 @@ """签到记录表。 每次签到一行,(user_id, signin_date) 唯一,天然防一天签两次。 + +2026-07 下线 `signin_boost_record`(签到膨胀):膨胀按固定 3000 金币补发、与广告实际收益 +脱钩,产品确认非设计内口径。签到弹窗的「看广告膨胀」改走 reward_video(按 eCPM 发,记在 +`ad_reward_record`)。历史发币流水 `coin_transaction.biz_type='signin_boost'` 保留不动。 - cycle_day: 1..7,7 天循环里今天落在第几档,决定发多少金币;断签后重置回 1 (周期长度 = rewards.SIGNIN_CYCLE_LEN,2026-06 由 14 天改 7 天一轮)。 - streak: 连续签到天数(不封顶),用于"已连续签到 N 天"展示;断签后重置回 1。 @@ -9,7 +13,7 @@ from __future__ import annotations from datetime import date, datetime -from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, UniqueConstraint, func +from sqlalchemy import Date, DateTime, ForeignKey, Integer, UniqueConstraint, func from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base @@ -42,29 +46,3 @@ class SigninRecord(Base): ) -class SigninBoostRecord(Base): - """签到后看广告膨胀记录。 - - 一天最多膨胀一次,补发金额等于当天签到原始奖励。独立表用于防并发重复补发, - 后续接入真实 S2S 广告 session 时可把 ad_ref_id 回填为广告会话/交易号。 - """ - - __tablename__ = "signin_boost_record" - __table_args__ = ( - UniqueConstraint("user_id", "signin_date", name="uq_signin_boost_user_date"), - ) - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - user_id: Mapped[int] = mapped_column( - Integer, ForeignKey("user.id"), index=True, nullable=False - ) - signin_date: Mapped[date] = mapped_column(Date, nullable=False) - coin_awarded: Mapped[int] = mapped_column(Integer, nullable=False) - ad_ref_id: Mapped[str | None] = mapped_column(String(64), nullable=True) - - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), nullable=False - ) - - def __repr__(self) -> str: # pragma: no cover - return f"" diff --git a/app/repositories/activity.py b/app/repositories/activity.py index f3e83b1..f016db6 100644 --- a/app/repositories/activity.py +++ b/app/repositories/activity.py @@ -1,6 +1,6 @@ """活跃口径唯一真源:worker(不活跃清零)与 admin(最近活跃/DAU)共用,防两处漂移。 -口径 = max(User.created_at, AnalyticsEvent[首页可见 show/home + 比价 + 领券], CouponPromptEngagement[claim_started])。 +口径 = max(User.created_at, AnalyticsEvent[首页可见 home_visible + 比价 + 领券], CouponPromptEngagement[claim_started])。 **不含 last_login_at**(登录/re-login 不代表在用 App);created_at 为恒非空基线。 清零/预警按北京自然日 0 点对齐(见 reset_cutoff)。 """ @@ -8,7 +8,7 @@ from __future__ import annotations from datetime import date, datetime, timedelta, timezone -from sqlalchemy import and_, func, or_, select +from sqlalchemy import func, select from sqlalchemy.orm import Session from app.core.rewards import CN_TZ, cn_today @@ -16,24 +16,18 @@ from app.models.analytics_event import AnalyticsEvent from app.models.coupon_state import CouponPromptEngagement # —— 活跃口径事件(与"用户管理"口径一致)—— -# 首页可见:前端埋点 event=show + page=home(组合判定,单个 event 名不足以区分,见 -# active_event_condition);其余为纯 event 名。 -HOME_VIEW_EVENT = "show" -HOME_VIEW_PAGE = "home" +# 首页可见:前端埋点确认 event=home_visible(首页进入可视区时触发,单一 event 名即可判定)。 +HOME_VISIBLE_EVENT = "home_visible" COMPARE_START_EVENT = "real_compare_start" # 发起比价(含浮窗触发) COUPON_START_EVENT = "real_coupon_start" # 发起领券 -# 纯 event 名即可判定的活跃事件(首页可见是 event+page 组合、不在此列) -ACTIVE_EVENTS = (COMPARE_START_EVENT, COUPON_START_EVENT) +ACTIVE_EVENTS = (HOME_VISIBLE_EVENT, COMPARE_START_EVENT, COUPON_START_EVENT) ACTIVE_ENGAGE_TYPE = "claim_started" # coupon_prompt_engagement 一键领取 def active_event_condition(): - """analytics_event 中算"活跃"的行为过滤:首页可见(event=show & page=home) + """analytics_event 中算"活跃"的行为过滤:首页可见(event=home_visible) ∪ 发起比价 ∪ 发起领券。worker 子查询与 admin 展示共用,单一真源。""" - return or_( - and_(AnalyticsEvent.event == HOME_VIEW_EVENT, AnalyticsEvent.page == HOME_VIEW_PAGE), - AnalyticsEvent.event.in_(ACTIVE_EVENTS), - ) + return AnalyticsEvent.event.in_(ACTIVE_EVENTS) def as_utc(value: datetime) -> datetime: diff --git a/app/repositories/ad_ecpm.py b/app/repositories/ad_ecpm.py index c8a1fbe..9013cae 100644 --- a/app/repositories/ad_ecpm.py +++ b/app/repositories/ad_ecpm.py @@ -13,6 +13,43 @@ from sqlalchemy.orm import Session from app.core import rewards from app.core.rewards import cn_today from app.models.ad_ecpm import AdEcpmRecord +from app.models.coupon_state import CouponSession + +MIN_REVENUE_EXPOSURE_MS = 1000 + + +def effective_ecpm_raw(ecpm_raw: str, exposure_ms: int | None) -> str: + """曝光不足一秒时保留展示记录,但把该条有效 eCPM 归零。""" + if exposure_ms is not None and exposure_ms < MIN_REVENUE_EXPOSURE_MS: + return "0" + return ecpm_raw + + +def attributable_trace_id( + db: Session, + *, + feed_scene: str | None, + trace_id: str | None, + exposure_ms: int | None = None, +) -> str | None: + """返回广告展示允许归属的业务 trace。 + + 领券任务可能在 Draw 广告异步加载完成前已经失败或被用户放弃。非完成终态先落库、 + 广告回调后到时,收益仍需保留在总广告报表中,但不能再挂到该死亡领券明细, + 因此清空关联 trace。其它场景、找不到 session、进行中或已完成状态保持原值, + 由客户端生命周期修复负责主防线。 + """ + if feed_scene != "coupon" or not trace_id: + return trace_id + session_status = db.execute( + select(CouponSession.status).where(CouponSession.trace_id == trace_id) + ).scalar_one_or_none() + if session_status not in {"failed", "abandoned"}: + return trace_id + # 已真实上墙但不足一秒的曝光要在终态明细中明确显示 0,而不是被误判成“未填充”。 + if exposure_ms is not None and exposure_ms < MIN_REVENUE_EXPOSURE_MS: + return trace_id + return None def create_ecpm_record( @@ -28,6 +65,7 @@ def create_ecpm_record( trace_id: str | None = None, app_env: str | None = None, our_code_id: str | None = None, + exposure_ms: int | None = None, ) -> AdEcpmRecord: """落一条 eCPM 上报记录。report_date 用北京时间当天,供按天聚合。 @@ -48,7 +86,7 @@ def create_ecpm_record( trace_id=trace_id, app_env=app_env, our_code_id=our_code_id, - ecpm_raw=ecpm_raw, + ecpm_raw=effective_ecpm_raw(ecpm_raw, exposure_ms), report_date=cn_today().isoformat(), ) db.add(rec) diff --git a/app/repositories/ad_pangle_revenue.py b/app/repositories/ad_pangle_revenue.py index 51ad39e..78fde1f 100644 --- a/app/repositories/ad_pangle_revenue.py +++ b/app/repositories/ad_pangle_revenue.py @@ -6,6 +6,7 @@ """ from __future__ import annotations +from collections.abc import Collection from typing import Any, TypedDict from sqlalchemy import func, select @@ -73,6 +74,7 @@ def aggregate_by_date( date_to: str, app_env: str | None = None, our_code_id: str | None = None, + our_code_ids: Collection[str] | None = None, ) -> list[PangleDateAgg]: """按日期汇总穿山甲收益(闭区间,北京时间),供报表趋势 + 合计。 @@ -97,6 +99,8 @@ def aggregate_by_date( stmt = stmt.where(AdPangleDailyRevenue.app_env == app_env) if our_code_id is not None: stmt = stmt.where(AdPangleDailyRevenue.our_code_id == our_code_id) + if our_code_ids is not None: + stmt = stmt.where(AdPangleDailyRevenue.our_code_id.in_(our_code_ids)) out: list[PangleDateAgg] = [] for report_date, rev, api_rev, imp in db.execute(stmt).all(): diff --git a/app/repositories/ad_reward.py b/app/repositories/ad_reward.py index f9fb16f..3854f12 100644 --- a/app/repositories/ad_reward.py +++ b/app/repositories/ad_reward.py @@ -41,6 +41,61 @@ def find_by_trans(db: Session, trans_id: str) -> AdRewardRecord | None: return _find_by_trans(db, trans_id) +def find_by_session(db: Session, user_id: int, ad_session_id: str) -> AdRewardRecord | None: + """按广告会话 id 查该用户本次广告的发奖记录,供客户端轮询弹窗金额(reward-result)。 + + 同一 ad_session_id 可能命中多条,**必须显式优先 granted**,不能只取最近一条: + - 客户端先上报 closed_early、S2S 随后才姗姗来迟 → 两条,granted 反而是后写的; + - record_reward_noshow 只在写入前查 granted,挡不住这种后到的竞态; + - 本地联调重复调 test-grant → 同 session 多条 granted(trans_id 各不相同)。 + granted 是唯一「真发了钱」的状态,取它才是权威金额;都没有再取最近一条,让客户端 + 知道没发的原因(capped/closed_early…)。按 user_id 收窄,防止拿别人的 session 探测结果。 + """ + granted = db.execute( + select(AdRewardRecord) + .where( + AdRewardRecord.user_id == user_id, + AdRewardRecord.ad_session_id == ad_session_id, + AdRewardRecord.status == "granted", + ) + .order_by(AdRewardRecord.created_at.desc()) + .limit(1) + ).scalars().first() + if granted is not None: + return granted + return db.execute( + select(AdRewardRecord) + .where( + AdRewardRecord.user_id == user_id, + AdRewardRecord.ad_session_id == ad_session_id, + ) + .order_by(AdRewardRecord.created_at.desc()) + .limit(1) + ).scalars().first() + + +def round_coin_total(db: Session, user_id: int, boost_round_id: str) -> int: + """本轮膨胀累计已发金币 = 该轮所有 granted 记录的 coin 之和(含刚发的这条)。 + + 客户端弹窗要显示的就是它:第 1 条弹 40、第 2 条弹 60(=40+20),点「放弃赚钱」后余额涨 60, + 三个数必须相等。之所以由服务端求和而不是客户端自己累加——客户端进程被杀/低内存重建后 + 本地累计就丢了,而发奖记录不会丢。 + + **必须带 user_id**:boost_round_id 是客户端生成的,不带 user_id 就等于让任何人拿别人的 + 轮 id 查别人发了多少。未发奖的状态(capped/closed_early/ecpm_missing)coin 本就是 0, + 这里按 status 过滤只是让意图显式。 + """ + return int( + db.execute( + select(func.coalesce(func.sum(AdRewardRecord.coin), 0)).where( + AdRewardRecord.user_id == user_id, + AdRewardRecord.boost_round_id == boost_round_id, + AdRewardRecord.status == "granted", + ) + ).scalar_one() + ) + + def _granted_today(db: Session, user_id: int, reward_date: str) -> int: return db.execute( select(func.count()) @@ -78,8 +133,12 @@ def grant_ad_reward( reward_scene: str = "reward_video", reward_name: str | None = None, raw: str | None = None, + boost_round_id: str | None = None, ) -> AdRewardRecord: - """福利页激励视频发奖(幂等 + 每日限额 + eCPM 公式)。""" + """福利页激励视频发奖(幂等 + 每日限额 + eCPM 公式)。 + + boost_round_id 只是随记录存下的标签(见 round_coin_total),**不参与任何发奖判定**。 + """ # #2 幂等:同 trans_id 已处理过 → 原样返回,不重复发 existing = _find_by_trans(db, trans_id) if existing is not None: @@ -112,7 +171,7 @@ def grant_ad_reward( trans_id=trans_id, user_id=user_id, coin=0, status="capped", reward_date=today, reward_name=reward_name, raw=raw, reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm, - app_env=src_app_env, our_code_id=src_code_id, + app_env=src_app_env, our_code_id=src_code_id, boost_round_id=boost_round_id, ) return _commit_record(db, rec, trans_id) @@ -123,7 +182,7 @@ def grant_ad_reward( trans_id=trans_id, user_id=user_id, coin=0, status="ecpm_missing", reward_date=today, reward_name=reward_name, raw=raw, reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=None, - app_env=src_app_env, our_code_id=src_code_id, + app_env=src_app_env, our_code_id=src_code_id, boost_round_id=boost_round_id, ) return _commit_record(db, rec, trans_id) @@ -140,7 +199,7 @@ def grant_ad_reward( trans_id=trans_id, user_id=user_id, coin=coin, status="granted", reward_date=today, reward_name=reward_name, raw=raw, reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm_raw, - app_env=src_app_env, our_code_id=src_code_id, + app_env=src_app_env, our_code_id=src_code_id, boost_round_id=boost_round_id, ) return _commit_record(db, rec, trans_id) @@ -206,8 +265,13 @@ def record_external_reward( raw: str | None = None, status: str = "granted", commit: bool = True, + boost_round_id: str | None = None, ) -> AdRewardRecord: - """记录非普通看视频场景的 S2S 回调幂等,发币由调用方业务仓储完成。""" + """记录非普通看视频场景的 S2S 回调幂等,发币由调用方业务仓储完成。 + + boost_round_id 同 grant_ad_reward:纯标签。签到膨胀场景的 coin 也会计入本轮累计 + (它的 coin 就是实发额),所以这里也要存,否则一轮里混了膨胀就会漏算。 + """ existing = _find_by_trans(db, trans_id) if existing is not None: return existing @@ -224,6 +288,7 @@ def record_external_reward( reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm, + boost_round_id=boost_round_id, ) db.add(rec) if commit: diff --git a/app/repositories/app_config.py b/app/repositories/app_config.py index 2cca9d0..3454ac9 100644 --- a/app/repositories/app_config.py +++ b/app/repositories/app_config.py @@ -142,3 +142,47 @@ def set_ad_config(db: Session, data: dict, *, admin_id: int, commit: bool = True else: db.flush() return row + + +# ── 华为审核开关(admin 可切,仅华为 ROM 客户端拉)──────────────────────────────── +# 同 ad_config:复用 AppConfig 表但不进 CONFIG_DEFS——它由「华为审核开关」专用页管理, +# 有自己的两态语义,混进通用系统配置页只会显示成一个没头没尾的 on/off。 +# default → 新手引导页(快速设置)强制展示,用户无法关闭(= 上线至今的现状) +# review → 快速设置的权限步左上角出现退出按钮,可直接进首页(过华为应用市场审核用) +# 空库 = default = 行为完全不变。客户端经 /api/v1/platform/huawei-review 拉取。 +HUAWEI_REVIEW_KEY = "huawei_review" +HUAWEI_REVIEW_MODES = ("default", "review") +_HUAWEI_REVIEW_DEFAULTS: dict[str, Any] = { + "mode": "default", +} + + +def get_huawei_review(db: Session) -> dict: + """读华为审核开关。DB 无 / 脏值一律回退 default(宁可不给退出按钮,也不误放开)。""" + row = db.get(AppConfig, HUAWEI_REVIEW_KEY) + merged = dict(_HUAWEI_REVIEW_DEFAULTS) + if row is not None and isinstance(row.value, dict): + merged.update(row.value) + if merged.get("mode") not in HUAWEI_REVIEW_MODES: + merged["mode"] = _HUAWEI_REVIEW_DEFAULTS["mode"] + return merged + + +def set_huawei_review(db: Session, mode: str, *, admin_id: int, commit: bool = True) -> AppConfig: + """admin 切换华为审核开关。非法 mode 抛 ValueError(路由转 400)。""" + if mode not in HUAWEI_REVIEW_MODES: + raise ValueError(f"invalid mode: {mode} (expected one of {list(HUAWEI_REVIEW_MODES)})") + row = db.get(AppConfig, HUAWEI_REVIEW_KEY) + value = {"mode": mode} + if row is None: + row = AppConfig(key=HUAWEI_REVIEW_KEY, value=value, updated_by_admin_id=admin_id) + db.add(row) + else: + row.value = value + row.updated_by_admin_id = admin_id + if commit: + db.commit() + db.refresh(row) + else: + db.flush() + return row diff --git a/app/repositories/signin.py b/app/repositories/signin.py index c65ce38..067fa7a 100644 --- a/app/repositories/signin.py +++ b/app/repositories/signin.py @@ -11,12 +11,11 @@ from dataclasses import dataclass from datetime import timedelta from sqlalchemy import select -from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.core import rewards from app.core.rewards import SIGNIN_CYCLE_LEN, cn_today -from app.models.signin import SigninBoostRecord, SigninRecord +from app.models.signin import SigninRecord from app.repositories import wallet as crud_wallet @@ -24,18 +23,6 @@ class AlreadySignedError(Exception): """今天已经签过了。""" -class NotSignedTodayError(Exception): - """今天尚未签到,不能膨胀。""" - - -class AlreadyBoostedError(Exception): - """今天签到奖励已经膨胀过。""" - - -class LastCycleDayBoostBlockedError(Exception): - """循环最后一天(第 SIGNIN_CYCLE_LEN 天)不允许签到膨胀。""" - - @dataclass class SigninStep: day: int # 1..14 @@ -141,69 +128,3 @@ def do_signin(db: Session, user_id: int) -> tuple[SigninRecord, int]: db.commit() db.refresh(record) return record, acc.coin_balance - - -def _today_record(db: Session, user_id: int) -> SigninRecord | None: - today = cn_today() - return db.execute( - select(SigninRecord).where( - SigninRecord.user_id == user_id, - SigninRecord.signin_date == today, - ) - ).scalar_one_or_none() - - -def boost_by_ad_ref( - db: Session, user_id: int, ad_ref_id: str -) -> SigninBoostRecord | None: - """按广告交易号查签到膨胀记录。S2S 发奖后客户端确认用。""" - return db.execute( - select(SigninBoostRecord).where( - SigninBoostRecord.user_id == user_id, - SigninBoostRecord.ad_ref_id == ad_ref_id, - ) - ).scalar_one_or_none() - - -def boost_today_signin( - db: Session, user_id: int, *, ad_ref_id: str | None = None, commit: bool = True -) -> tuple[SigninBoostRecord, int]: - """签到后看广告膨胀:固定补发配置金币。返回 (膨胀记录, 补发后余额)。""" - record = _today_record(db, user_id) - if record is None: - raise NotSignedTodayError - if record.cycle_day == SIGNIN_CYCLE_LEN: - raise LastCycleDayBoostBlockedError - - today = record.signin_date - existing = db.execute( - select(SigninBoostRecord).where( - SigninBoostRecord.user_id == user_id, - SigninBoostRecord.signin_date == today, - ) - ).scalar_one_or_none() - if existing is not None: - raise AlreadyBoostedError - - boost = SigninBoostRecord( - user_id=user_id, - signin_date=today, - coin_awarded=rewards.get_signin_boost_coin(db), - ad_ref_id=ad_ref_id, - ) - db.add(boost) - try: - acc, _ = crud_wallet.grant_coins( - db, user_id, boost.coin_awarded, - biz_type="signin_boost", ref_id=ad_ref_id or today.isoformat(), - remark=f"签到膨胀 第{record.cycle_day}天", - ) - if commit: - db.commit() - else: - db.flush() - except IntegrityError as e: - db.rollback() - raise AlreadyBoostedError from e - db.refresh(boost) - return boost, acc.coin_balance diff --git a/app/schemas/ad.py b/app/schemas/ad.py index ceb74af..9a6df95 100644 --- a/app/schemas/ad.py +++ b/app/schemas/ad.py @@ -43,6 +43,35 @@ class AdRewardStatusOut(BaseModel): watch_seconds_remaining: int = Field(0, description="今日剩余可观看秒数;limit=0 时客户端不据此拦截") +class AdRewardResultOut(BaseModel): + """按 ad_session_id 查本次广告的**权威发奖结果**(福利页看完视频的弹窗金额只认它)。 + + S2S 回调是异步的,客户端看完广告立刻轮询多半还查不到记录 —— 这种「还没到账」返回 + 200 + status='pending' 让客户端重试,**不返 404**:404 只应表示路由不存在,两者混在 + 一起客户端没法区分「后端没部署」和「再等等」。纯只读,不产生任何奖励。 + """ + + ad_session_id: str = Field(..., description="回显请求的广告会话 id") + status: str = Field( + ..., + description="pending(S2S 未到账,客户端应继续轮询) / granted(已发奖) / capped(当日超限未发) / " + "ecpm_missing(缺 eCPM 未发) / closed_early(提前关闭未发);其余同 AdRewardRecord.status。" + "客户端只在 granted 且 coin>0 时弹窗,其它一律不弹(不显示假数字)", + ) + coin: int | None = Field( + None, + description="本次实发金币:granted 为真实到账额;未发奖的状态为 0;pending 为 null", + ) + round_coin: int | None = Field( + None, + description="**本轮膨胀累计已发金币**(含本条)——客户端「恭喜累计获得奖励」弹窗显示的就是它。" + "轮 = 用户点「去膨胀」到点「放弃赚钱」之间连看的若干条广告,边界由客户端的 boost_round_id 定。" + "本条不是 granted(capped/closed_early/…)时**仍返本轮累计**,只是这条按 0 计。" + "pending(没记录,取不到轮 id)、或该记录没有 boost_round_id(老客户端 / extra 丢失)时为 null," + "客户端见 null 退回只显示单条 coin", + ) + + class EcpmReportIn(BaseModel): """客户端上报一次广告展示的 eCPM(内部收益统计/对账)。 @@ -69,6 +98,12 @@ class EcpmReportIn(BaseModel): description="本次比价/领券 trace_id(信息流场景带上):把这条展示收益归属到对应比价/领券," "供领券数据/比价记录看板聚合本场广告收益;激励视频/福利为空", ) + exposure_ms: int | None = Field( + None, + ge=0, + le=86_400_000, + description="本条广告真实在屏曝光毫秒数;小于 1000ms 时收益强制按 0 计算。旧客户端不传则保持原口径", + ) app_env: str | None = Field( None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)" ) @@ -105,13 +140,20 @@ class TestGrantIn(BaseModel): reward_scene: str = Field( "reward_video", - description="模拟发奖场景:reward_video(普通激励视频) / signin_boost(签到膨胀)", + description="模拟发奖场景。当前只支持 reward_video(普通激励视频);signin_boost(签到膨胀)" + "已于 2026-07 下线,传它会 422", ) ad_session_id: str | None = Field( None, min_length=8, max_length=64, description="本次广告会话 id(与 ecpm-report 同值)。reward_video 场景下据此查回客户端" "已上报的真实 eCPM 来按公式发奖;查不到或 eCPM≤0 时兜底 200,保证本地联调仍出非零金币", ) + boost_round_id: str | None = Field( + None, max_length=64, + description="本次广告属于哪一轮膨胀。正式链路走穿山甲 S2S 的 mediaExtra,test-grant 不经 S2S、" + "拿不到 extra,故在 body 里补一个——不传的话 debug 包 reward-result 的 round_coin 恒为 null," + "「弹窗 40 → 60 → toast +60」那套验收在本地跑不起来", + ) class TestGrantOut(BaseModel): @@ -119,7 +161,7 @@ class TestGrantOut(BaseModel): granted: bool = Field(..., description="本次是否真的发了金币(达每日上限则 False)") status: str = Field( - ..., description="granted / capped / not_signed / already_boosted / last_day / unknown_scene" + ..., description="granted / capped / ecpm_missing / unknown_scene" ) coin: int = Field(..., description="本次发放金币(capped 时为 0)") used_today: int = Field(..., description="今日已成功发奖次数") diff --git a/app/schemas/platform.py b/app/schemas/platform.py index 13ca209..e56b035 100644 --- a/app/schemas/platform.py +++ b/app/schemas/platform.py @@ -46,6 +46,17 @@ class AdConfigPublicOut(BaseModel): withdrawal_ad_enabled: bool # 提现激励视频开关(关=客户端直接放行提现) +class HuaweiReviewOut(BaseModel): + """华为审核开关下发给客户端(不鉴权,引导页在登录前就要展示)。 + + 客户端只需读 onboarding_closable 决策;mode 仅供排查问题时看后台切成了哪态。 + 只有华为 ROM(HarmonyOS/EMUI,不含荣耀 MagicOS)的客户端才会来拉这个端点。 + """ + + mode: str = "default" # default | review + onboarding_closable: bool = False # 快速设置权限步是否允许用户退出(mode == review) + + class AppVersionOut(BaseModel): """最新 App 版本信息(OTA 检查更新,不鉴权)。 diff --git a/app/schemas/welfare.py b/app/schemas/welfare.py index d6ba63a..a9aa811 100644 --- a/app/schemas/welfare.py +++ b/app/schemas/welfare.py @@ -223,16 +223,6 @@ class SigninResultOut(BaseModel): coin_balance: int = Field(..., description="签到后金币余额") -class SigninBoostRequest(BaseModel): - ad_ref_id: str | None = Field(None, description="广告会话/交易号。当前开发期可空,后续接 S2S 时回填") - - -class SigninBoostResultOut(BaseModel): - coin_awarded: int = Field(..., description="本次膨胀补发金币") - coin_balance: int = Field(..., description="膨胀补发后金币余额") - signin_date: str = Field(..., description="被膨胀的签到日期 YYYY-MM-DD") - - # ===== 任务 ===== class TaskOut(BaseModel): diff --git a/docs/api/README.md b/docs/api/README.md index d5e20da..03059c9 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -82,7 +82,6 @@ | **签到**(前缀 `/api/v1/signin`) ||| | 25 | `GET /api/v1/signin/status` | Bearer | [详情](./signin/signin-status.md) | | 26 | `POST /api/v1/signin` | Bearer | [详情](./signin/signin-do.md) | -| 26a | `POST /api/v1/signin/boost` | Bearer | [详情](./signin/signin-boost.md) | | **任务**(前缀 `/api/v1/tasks`) ||| | 27 | `GET /api/v1/tasks` | Bearer | [详情](./tasks/tasks-list.md) | | 28 | `POST /api/v1/tasks/{task_key}/claim` | Bearer | [详情](./tasks/tasks-claim.md) | @@ -93,6 +92,7 @@ | **看广告发奖**(前缀 `/api/v1/ad`) ||| | 32 | `GET /api/v1/ad/pangle-callback` | 验签 | [详情](./ad/ad-pangle-callback.md) | | 33 | `GET /api/v1/ad/reward-status` | Bearer | [详情](./ad/ad-reward-status.md) | +| 33a | `GET /api/v1/ad/reward-result/{ad_session_id}` | Bearer | [详情](./ad/ad-reward-result.md)(本次实发金币 + 本轮膨胀累计 `round_coin`,弹窗数字用它) | | 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad/ad-test-grant.md) | | 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad/ad-ecpm-report.md) | | 35a | `POST /api/v1/ad/feed-reward` | Bearer | [详情](./ad/ad-feed-reward.md) | @@ -133,6 +133,7 @@ | 40a | `GET /api/v1/platform/flags` | 无 | [详情](./platform/platform-flags.md)(客户端运营 feature flag,比价/领券期广告开关等,拉取后缓存) | | 40b | `GET /api/v1/platform/ad-config` | 无 | [详情](./platform/platform-ad-config.md)(客户端拉广告配置:穿山甲 app_id+各位ID+各场景开关;不含验签密钥) | | 40c | `GET /api/v1/platform/app-version` | 无 | [详情](./platform/platform-app-version.md)(最新 App 版本,OTA 检查更新;与本机 versionCode 比) | +| 40d | `GET /api/v1/platform/huawei-review` | 无 | [详情](./platform/platform-huawei-review.md)(华为审核开关:快速设置权限步能否被用户关闭;仅华为 ROM 客户端拉) | | **微信支付回调**(前缀 `/api/v1/wxpay`) ||| | W1 | `POST /api/v1/wxpay/transfer-auth-notify` | 无 | 免确认收款授权结果通知(一期 stub:仅应答 200 不验签不改账,授权状态靠主动查询兜底)(无单独文档) | | **CPS 群发短链落地**(**无前缀**,挂域名根;公网不鉴权) ||| @@ -171,6 +172,7 @@ | A12 | `GET /admin/api/ad-revenue-report` | admin | [详情](./admin/ad/admin-ad-revenue-report.md)(广告收益报表:分页/场景/`app_env` 筛 + **DAU/ARPU** #120;真实收益侧接穿山甲日表 #92) | | A13 | `GET / PATCH /admin/api/ad-config` | operator/finance | 广告配置(穿山甲 ID/验签密钥/各场景开关;C 端只读版见 40b)(无单独文档,见 `app/admin/routers/ad_config.py`) | | A14 | `GET /admin/api/config`、`PATCH /config/{key}` | operator/finance | 运营可配置项([app_config](../database/app_config.md):奖励常量/提现地板价等;#117 修系统配置下发)(无单独文档,见 `app/admin/routers/config.py`) | +| A16 | `GET / PATCH /admin/api/huawei-review` | operator/tech | 华为审核开关(快速设置权限步能否被用户关闭,落 `app_config.huawei_review`;C 端只读版见 40d)(无单独文档,见 `app/admin/routers/huawei_review.py`) | | **A·管理员与角色**(super_admin):`GET`/`POST` `/admins`、`PATCH`/`DELETE` `/admins/{id}`(#126 删除+`pages_override`)、`GET`/`POST` `/roles`、`GET /roles/catalog`、`PATCH`/`DELETE` `/roles/{id}`(#117/#126 自定义角色) ||| [列表](./admin/admins/admin-admins-list.md) / [建](./admin/admins/admin-admin-create.md) / [改+删](./admin/admins/admin-admin-update.md) / [角色](./admin/admin-roles.md) | | A15 | `GET /admin/api/audit-logs` | admin | [详情](./admin/admin-audit-logs.md) | | **A·CPS 运营台**:群/活动 CRUD、`POST /referral-links`、`POST /orders/reconcile`(美团+京东 #90)、`GET /orders`、`/stats`、群 `timeseries`/`daily`/`wx-users`/`day-users`(#79) ||| [详情](./admin/admin-cps.md) | diff --git a/docs/api/ad/ad-pangle-callback.md b/docs/api/ad/ad-pangle-callback.md index b059afd..5233cc9 100644 --- a/docs/api/ad/ad-pangle-callback.md +++ b/docs/api/ad/ad-pangle-callback.md @@ -15,7 +15,15 @@ GroMore 以 GET 回调,关键参数: | `trans_id` | string | 交易号(**幂等键** + **唯一参与签名的字段**) | | `reward_name` | string | 奖励名(广告位配置,入库备注) | | `ecpm` | string\|null | GroMore 回调携带的 eCPM。普通激励视频优先用它计算金币 | -| `extra` / `gromoreExtra` / `gromore_extra` | string | 客户端透传 JSON。支持 `ad_session_id`、`reward_scene`;`reward_scene=signin_boost` 表示签到膨胀 | +| `extra` / `gromoreExtra` / `gromore_extra` | string | 客户端透传 JSON。支持 `ad_session_id`、`reward_scene`、`srv_env`、`boost_round_id` | + +### `extra` 里的 `boost_round_id` + +客户端生成的「这条广告属于哪一轮膨胀」标签(32 位十六进制,同 `ad_session_id` 格式),随发奖记录存进 `ad_reward_record.boost_round_id`。 + +**它不参与任何发奖判定** —— 发多少、发不发完全不受影响,只是让 [`/ad/reward-result`](./ad-reward-result.md) 能把同一轮的 granted 记录求和成 `round_coin`(客户端「恭喜累计获得奖励」弹窗显示的数)。 + +轮次边界由客户端定(只有它知道用户点没点「放弃赚钱」):点「去膨胀」新生成一个 → 点「继续看视频膨胀」复用同一个 → 点「放弃赚钱」/ ✕ / 返回 / 到每日上限 / 跨天 则丢弃。不带此字段(老客户端 / GroMore 偶发丢 extra)时存 NULL,`round_coin` 返 `null`。 | `mediation_rit` | string | 代码位 ID(GroMore 带,目前仅入 raw 备查) | | `prime_rit` | string | 广告位 ID(同上) | | `adn_name` | string | 实际出广告的 ADN 名(同上,可用于收益分析) | @@ -40,6 +48,6 @@ GroMore 以 GET 回调,关键参数: **发奖唯一可信入口**:验签 → 取 `user_id`/`extra` → 按 `reward_scene` 分流 → 幂等处理(按 `trans_id` 去重)。客户端不直接发奖,被破解也刷不到钱。 - `reward_scene=reward_video` 或缺省:普通激励视频。金币按 `eCPM / 1000 * eCPM因子 * 当日次数因子 * 10000` 计算;若回调没有 `ecpm`,会按 `extra.ad_session_id` 查客户端 `/ad/ecpm-report` 的上报值;两边都没有 eCPM 时不发币,记录 `status=ecpm_missing`。 -- `reward_scene=signin_boost`:签到膨胀。要求用户当天已签到且不是 Day14;看完视频固定发 `2000` 金币,写 `signin_boost_record` 与 `coin_transaction.biz_type=signin_boost`。 +- ~~`reward_scene=signin_boost`~~(签到膨胀):**2026-07 已下线**。它按固定 3000 金币发、与广告实际收益脱钩,产品确认非设计内口径。签到弹窗的「看广告膨胀」现与福利页看视频同走 `reward_video`。现在传 `signin_boost` 会落到「未知场景」分支(不发币,`status=unknown_scene`)。 - 未知 `reward_scene`:不发币,记录 `status=unknown_scene`,返回 `is_verify=false/reason=1`。 - 验签过但参数缺/坏或 user 不存在 → 不发(`is_verify=false` + `reason`);granted / capped / ecpm_missing / 业务不满足已记录 → `is_verify=true` + `reason=0`。 diff --git a/docs/api/ad/ad-reward-result.md b/docs/api/ad/ad-reward-result.md new file mode 100644 index 0000000..273e16a --- /dev/null +++ b/docs/api/ad/ad-reward-result.md @@ -0,0 +1,66 @@ +# GET /api/v1/ad/reward-result/{ad_session_id} — 查本次广告的权威发奖结果 + 本轮累计 + +客户端看完激励视频后轮询本接口,拿**本次实发金币**和**本轮累计**用于「恭喜累计获得奖励」弹窗。不再用余额差 / `coin_per_ad` 估算。 + +**纯只读**:发奖仍只由验签过的 S2S 回调完成,本接口不写库、不产生任何奖励。按 `user_id` 收窄,被刷也只能查到自己的记录。 + +## 鉴权 + +需要 Bearer token。 + +## 路径参数 + +| 参数 | 类型 | 约束 | 说明 | +|---|---|---:|---| +| `ad_session_id` | string | 长度 8~64 | 本次广告会话 id,客户端生成,与 `mediaExtra` / `ecpm-report` 同值 | + +## 响应 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `ad_session_id` | string | 回显请求值 | +| `status` | string | `pending`(S2S 未到账,继续轮询) / `granted` / `capped`(当日超限) / `ecpm_missing` / `closed_early`(提前关闭) | +| `coin` | int \| null | **本条**实发金币。granted 为真实到账额;未发奖的状态为 0;pending 为 `null` | +| `round_coin` | int \| null | **本轮累计已发金币**(含本条) ← 弹窗显示的就是它 | + +```json +{ "ad_session_id": "3f2a9c1b7e4d8a60", "status": "granted", "coin": 20, "round_coin": 60 } +``` + +### `round_coin` 的口径 + +「轮」= 用户点「去膨胀」到点「放弃赚钱」之间连看的若干条广告,边界由客户端的 `boost_round_id` 定(见 [ad-pangle-callback](./ad-pangle-callback.md))。 + +```sql +SELECT COALESCE(SUM(coin), 0) FROM ad_reward_record +WHERE user_id = :user_id -- 恒带,轮 id 是客户端生成的不可跨用户信任 + AND boost_round_id = :该会话记录的 boost_round_id + AND status = 'granted' +``` + +由服务端求和而非客户端自己累加:客户端进程被杀 / 低内存重建后本地累计会丢,发奖记录不会。 + +**要守住的不变量:弹窗数字 == 本轮实际到账之和 == 用户看到的余额涨幅。** 三者对不上,用户就会认为少发了钱。 + +| 情形 | `round_coin` | +|---|---| +| 本条 `granted` | 本轮累计(含本条) | +| 本条 `capped` / `closed_early` / `ecpm_missing` | **仍返本轮累计**,该条按 0 计(撞上限那下的 toast 要能显示前几条的总额,不能是空) | +| `status=pending`(没记录) | `null` —— 连属于哪一轮都不知道。**不是 0**,0 会被读成「本轮没赚到」 | +| 该记录没有 `boost_round_id`(老客户端 / extra 丢失) | `null`,客户端退回只显示单条 `coin` | + +## 错误 + +- `401`: 未登录 +- `422`: `ad_session_id` 长度不在 8~64 + +**查不到记录不返 404**,而是 200 + `status="pending"`。404 只应表示路由不存在;两者混在一起客户端没法区分「后端没部署」和「再等等」。 + +## 实现注意 + +同一 `ad_session_id` 可能有多条记录,取值时**显式优先 `granted`**,不能只取最近一条: + +- 客户端先报 `closed_early`、S2S 随后姗姗来迟 → 两条,`granted` 反而是后写的 +- 本地联调重复调 `test-grant` → 同 session 多条 `granted`(`trans_id` 各不相同) + +都没有 `granted` 才取最近一条,让客户端知道没发的原因。 diff --git a/docs/api/ad/ad-test-grant.md b/docs/api/ad/ad-test-grant.md index 490f96b..60891e8 100644 --- a/docs/api/ad/ad-test-grant.md +++ b/docs/api/ad/ad-test-grant.md @@ -9,7 +9,8 @@ | 字段 | 类型 | 必填 | 默认 | 说明 | |---|---|---|---|---| -| `reward_scene` | string | 否 | `reward_video` | 模拟发奖场景。`reward_video`=普通激励视频;`signin_boost`=签到膨胀 | +| `reward_scene` | string | 否 | `reward_video` | 模拟发奖场景。当前**只支持** `reward_video`;`signin_boost`(签到膨胀)已于 2026-07 下线,传它返 `422` | +| `boost_round_id` | string | 否 | `null` | 本次广告属于哪一轮膨胀。正式链路走 S2S 的 `mediaExtra`,本接口不经 S2S 拿不到 extra,故由 body 补。**不传的话 debug 包 `/ad/reward-result` 的 `round_coin` 恒为 `null`**,「弹窗 40 → 60 → toast +60」那套累计验收在本地跑不起来 | | `ad_session_id` | string(8~64) \| null | 否 | null | 本次广告会话 id(与 [ecpm-report](./ad-ecpm-report.md) 同值)。**仅 `reward_video` 场景生效**:据此查回客户端已上报的真实 eCPM,走与正式发奖相同的公式发奖;查不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍出非零金币 | ## 出参 @@ -33,4 +34,4 @@ `reward_scene=reward_video` 时按上面 `ad_session_id` 查回的真实 eCPM 走金币公式发奖(取不到兜底 200)——便于本地用 [admin 金币审计](./admin-ad-coin-audit.md) 核对「看广告→金币」是否按公式计算。 -`reward_scene=signin_boost` 时复用签到膨胀业务规则:必须当天已签到、非第 14 天、当天未膨胀过,成功后写入 `signin_boost` 金币流水。它让已登录客户端能自助发奖 = 绕过反作弊,**严禁在生产开启**。 +它让已登录客户端能自助发奖 = 绕过反作弊,**严禁在生产开启**。 diff --git a/docs/api/admin/ad/admin-ad-revenue-report.md b/docs/api/admin/ad/admin-ad-revenue-report.md index b4c2b07..a1c1132 100644 --- a/docs/api/admin/ad/admin-ad-revenue-report.md +++ b/docs/api/admin/ad/admin-ad-revenue-report.md @@ -28,6 +28,8 @@ | `user_id` | int | 全部 | 只看某用户;不传=所有用户 | | `ad_type` | string | 全部 | `reward_video` / `feed` / `draw`;不传=全部类型 | | `feed_scene` | string | 全部 | `comparison`(比价)/ `coupon`(领券)/ `welfare`(福利);**全局筛选**,同时作用于明细 / 合计 / `daily`·`hourly` 趋势;不传=全部场景 | + | `app_env` | string | 全部 | `prod`=正式应用 / `test`=测试应用;同时过滤客户端预估与穿山甲汇总 | + | `revenue_scope` | string | `all` | `business`=仅业务代码位,用于同口径对账 / `all`=应用全部代码位,包含广告测试等非业务曝光 | | `granularity` | string | `day` | `day`=按天 / `hour`=按小时(聚合键再加北京时间小时 0–23);**区间>1 天建议用 day** | | `limit` | int(1~1000) | 500 | **每页条数**(分页大小);`total`/`total_*`/`daily`/`hourly` 按全量统计不受分页影响 | | `offset` | int(≥0) | 0 | 分页偏移(已跳过条数)=(页码−1)×`limit` | @@ -131,5 +133,5 @@ - **历史 Draw 不可拆**:迁移(Draw→普通信息流)前,Draw 发奖混在 `ad_feed_reward_record` 且无类型标记,金币侧统一记 `feed`;迁移后 Draw 不再产生新数据。展示侧 `ad_type` 由客户端上报区分,故 `draw` 桶基本为空。 - **来源字段从上线起齐全**:`app_env`/`our_code_id` 是本期新增列,历史记录为 NULL(报表来源列留空)。 - **逐条/明细的收益是预估**:`items[].revenue_yuan` 基于客户端上报的 eCPM 折算,非穿山甲后台结算值。 -- **穿山甲后台收益(汇总/趋势级)**:`total_pangle_revenue_yuan`(预估 `revenue`)与 `total_pangle_api_revenue_yuan`(收益Api `api_revenue`,更接近结算)来自穿山甲 **GroMore 数据 API**(`integrations/pangle_report` + `scripts/sync_pangle_revenue` 按天 T+1 拉取入 [ad_pangle_daily_revenue](../database/ad_pangle_daily_revenue.md))。穿山甲**不提供分用户/设备/类型/场景维度**(官方明确),最细到 日期×应用×代码位,故只用于汇总与按天趋势的对照,**不挂到逐条事件行**;且仅在全量视图(未按 user/类型/场景过滤)展示。配置见 `.env` 的 `PANGLE_REPORT_*`。 +- **穿山甲后台收益(汇总/趋势级)**:`total_pangle_revenue_yuan`(预估 `revenue`)与 `total_pangle_api_revenue_yuan`(收益Api `api_revenue`,更接近结算)来自穿山甲 **GroMore 数据 API**(`integrations/pangle_report` + `scripts/sync_pangle_revenue` 按天 T+1 拉取入 [ad_pangle_daily_revenue](../database/ad_pangle_daily_revenue.md))。穿山甲**不提供分用户/设备/类型/场景维度**(官方明确),最细到 日期×应用×代码位,故只用于汇总与按天趋势的对照,**不挂到逐条事件行**;且仅在未按 user/类型/场景过滤时展示。`app_env` 与 `revenue_scope` 会同时过滤客户端和穿山甲数据,其中 `business` 排除广告测试等非业务代码位。配置见 `.env` 的 `PANGLE_REPORT_*`。 - **对账聚合级 + 逐条下钻**:行级 `matched` 给出该组(用户×类型×应用×代码位)应发是否==实发;**展开 `records` 即可看该组逐条明细**(eCPM/因子1/份数/LT/因子2/应发/实发/一致)定位到具体记录。独立逐条审计接口 [admin-ad-coin-audit](./admin-ad-coin-audit.md) 仍保留(同一复算口径,可全局按场景/只看不符筛选)。 diff --git a/docs/api/admin/admin-stats-overview.md b/docs/api/admin/admin-stats-overview.md index 6165445..2c7f089 100644 --- a/docs/api/admin/admin-stats-overview.md +++ b/docs/api/admin/admin-stats-overview.md @@ -37,8 +37,8 @@ | `feed_ad_watch_count` | int | 信息流广告有效完成视频数(`ad_feed_reward_record.status=granted`) | | `signin_coin_total` | int | 签到累计发放金币(`biz_type=signin`) | | `signin_count` | int | 签到次数(`signin_record`) | -| `signin_boost_coin_total` | int | 签到膨胀累计发放金币(`biz_type=signin_boost`) | -| `signin_boost_watch_count` | int | 签到膨胀有效视频数(`signin_boost_record`) | +| `signin_boost_coin_total` | int | **历史口径**:签到膨胀累计发放金币(`biz_type=signin_boost`)。功能已下线,数字不再增长,保留供对账 | +| `signin_boost_watch_count` | int | **历史口径**:签到膨胀次数。膨胀 2026-07 已下线、`signin_boost_record` 表已 drop,改数 `coin_transaction.biz_type='signin_boost'` 的入账笔数(一次膨胀 = 一笔,与原口径等价),只会停在历史值不再增长 | **DashboardCash** | 字段 | 类型 | 说明 | diff --git a/docs/api/platform/platform-huawei-review.md b/docs/api/platform/platform-huawei-review.md new file mode 100644 index 0000000..b2fa0eb --- /dev/null +++ b/docs/api/platform/platform-huawei-review.md @@ -0,0 +1,36 @@ +# GET /api/v1/platform/huawei-review — 华为审核开关 + +> 所属:Platform 组(前缀 `/api/v1/platform`) | 鉴权:无 | [← 返回 API 索引](../README.md) + +华为应用市场审核要求:新手引导的**「快速设置」权限步必须可被用户关闭**(引导视频页不在要求内)。本端点把运营后台配的开关下发给客户端,决定该步左上角是否出现退出按钮。 + +**不鉴权**:引导页在登录之前就展示,此时客户端必然没有 token。 + +值来自 `app_config` 表的 `huawei_review` 行(admin 页 `GET / PATCH /admin/api/huawei-review` 可改),空库回退 `default`。 + +## 入参 + +无。 + +## 出参 + +响应 `200`:`HuaweiReviewOut` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `mode` | string | `default`(强制展示,不可关闭 = 上线至今的现状)/ `review`(可关闭,过审用)。仅供排查时看后台切成了哪态 | +| `onboarding_closable` | bool | 快速设置权限步是否允许用户退出(= `mode == "review"`)。**客户端只读这一个字段决策** | + +Mock 出参: +```json +{ + "mode": "review", + "onboarding_closable": true +} +``` + +## 说明 +- **只有华为 ROM 客户端会来拉**(HarmonyOS / EMUI;荣耀 MagicOS 不拉)。机型判断在客户端做(`OemDetector`),服务端不看 UA,也就不用维护机型名单。 +- 客户端在进新手引导前拉一次并本地缓存;请求失败/超时用上次缓存值,从未拉到过则按 `onboarding_closable=false`(宁可不给退出按钮,也不误放开)。 +- 脏值兜底:DB 里 `mode` 不在枚举内时服务端一律回退 `default`。 +- 切回 `default` 即可一键收回退出按钮(审核通过后无需发版)。 diff --git a/docs/api/signin/signin-boost.md b/docs/api/signin/signin-boost.md deleted file mode 100644 index a1ec64c..0000000 --- a/docs/api/signin/signin-boost.md +++ /dev/null @@ -1,33 +0,0 @@ -# POST /api/v1/signin/boost — 签到后看广告膨胀金币 - -用户 Day1-Day13 当天已签到后,看完一条激励视频,由穿山甲 S2S 回调固定补发 2000 金币。本接口只用于 S2S 发奖后的确认。 - -## 鉴权 - -需要 Bearer token。 - -## 请求体 - -| 字段 | 类型 | 必填 | 说明 | -|---|---|---:|---| -| `ad_ref_id` | string | 是 | 穿山甲 S2S 回调的 `trans_id`。回调需先以 `extra.reward_scene=signin_boost` 完成发奖 | - -## 响应 - -| 字段 | 类型 | 说明 | -|---|---|---| -| `coin_awarded` | int | 本次膨胀补发金币 | -| `coin_balance` | int | 补发后的金币余额 | -| `signin_date` | string | 被膨胀的签到日期,格式 `YYYY-MM-DD` | - -## 错误 - -- `401`: 未登录 -- `409`: 缺少/无效广告回调记录,非本人广告,回调未发奖,当天未签到,Day14,或当天已经膨胀过 - -## 数据写入 - -- 本接口不直接发奖;实际写入发生在 `/ad/pangle-callback` 的 `reward_scene=signin_boost` 分支。 -- 回调写 `signin_boost_record` 新增一行,用 `(user_id, signin_date)` 唯一约束防重复。 -- 回调使 `coin_account` 增加固定 `2000` 金币。 -- 回调写入 `coin_transaction.biz_type=signin_boost`。 diff --git a/docs/database/OVERVIEW.md b/docs/database/OVERVIEW.md index 5f5f525..edbfd1d 100644 --- a/docs/database/OVERVIEW.md +++ b/docs/database/OVERVIEW.md @@ -35,7 +35,7 @@ | 资产卡 / 钱包余额 | [`coin_account`](./coin_account.md) | 一用户一行的金币+现金余额快照 | | 金币明细 | [`coin_transaction`](./coin_transaction.md) | 每次金币变动一笔流水 | | 现金明细 | [`cash_transaction`](./cash_transaction.md) | 每次现金变动一笔流水(分) | -| 每日签到 | [`signin_record`](./signin_record.md) + [`signin_boost_record`](./signin_boost_record.md) | 7 天循环发币;签到后看广告可膨胀一次 | +| 每日签到 | [`signin_record`](./signin_record.md) | 7 天循环发币。签到弹窗的「看广告膨胀」2026-07 起走 `reward_video`(按 eCPM 发,记 `ad_reward_record`),不再有独立的膨胀表 | | 一次性任务(开消息提醒等) | [`user_task`](./user_task.md) | 领一次发币 | | 看激励视频赚金币 | [`ad_reward_record`](./ad_reward_record.md) + [`ad_watch_log`](./ad_watch_log.md) + [`ad_ecpm_record`](./ad_ecpm_record.md) | 独立数据流:发奖 / 旧版观看时长 / 收益对账 | | 信息流/Draw 广告结算 | [`ad_feed_reward_record`](./ad_feed_reward_record.md) | 每展示满 10 秒累计一份奖励,完成后一次性入账;`ad_type`(feed/draw)+`feed_scene`(compare/coupon)分形态/场景 | @@ -102,12 +102,11 @@ | 注销 `DELETE /user` | `user` | U(软删:`phone→deleted_`、`status=deleted`) | | 绑/解绑微信 `POST /wallet/bind-wechat`、`/unbind-wechat` | `user`.wechat_* | U | | 签到 `POST /signin/do` | `signin_record`(C) + `coin_account`(U) + `coin_transaction`(C `signin`) | 同事务 | -| 签到膨胀 `POST /signin/boost` | `signin_boost_record`(C) + `coin_account`(U) + `coin_transaction`(C `signin_boost`) | 同事务;同日一次 | | 领任务 `POST /tasks/claim` | `user_task`(C) + `coin_account`(U) + `coin_transaction`(C `task_`) | 同事务 | | 金币兑现金 `POST /wallet/exchange` | `coin_account`(U) + `coin_transaction`(C `exchange_out` −) + `cash_transaction`(C `exchange_in` +) | 同事务 | | 发起提现 `POST /wallet/withdraw` | `withdraw_order`(C `reviewing`,记 `source`) + `coin_account`(U 按 source 扣对应余额) + 流水(C −:`cash_transaction.withdraw` 或 `invite_cash_transaction.invite_withdraw`) | 同事务,**不打款**;#121 按 `source` 分账 | | 查提现状态 / 用户取消 `GET /wallet/withdraw/status` | `withdraw_order`(U) + 失败→对应账本退款流水(C `withdraw_refund` / `invite_withdraw_refund` +) | | -| 穿山甲发奖 S2S 回调 `POST /ad/pangle-callback` | `ad_reward_record`(C)+ granted→`coin_account`(U)+`coin_transaction`(C `reward_video`/`signin_boost`) | `trans_id` 幂等 | +| 穿山甲发奖 S2S 回调 `POST /ad/pangle-callback` | `ad_reward_record`(C)+ granted→`coin_account`(U)+`coin_transaction`(C `reward_video`) | `trans_id` 幂等 | | 看广告时长上报 `POST /ad/watch-report` | `ad_watch_log`(C) | | | 广告 eCPM 上报 `POST /ad/ecpm-report` | `ad_ecpm_record`(C) | | | 信息流广告结算 `POST /ad/feed-reward` | `ad_feed_reward_record`(C)+ granted→`coin_account`(U)+`coin_transaction`(C `feed_ad_reward`) | `client_event_id` 幂等 | @@ -175,7 +174,7 @@ ## 三、表间关系 & Join Key ### 硬外键(数据库 FK 约束) -- **19 张用户维度表 `.user_id` → `user.id`**:`coin_account`(同时是 PK)、`coin_transaction`、`cash_transaction`、`invite_cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`(同时是 PK)、`signin_record`、`signin_boost_record`、`user_task`、`comparison_record`(2026-07 起 `user_id` **可空**——harvest 帧0 建行时软鉴权可能拿不到)、`comparison_milestone_claim`、`savings_record`、`ad_reward_record`、`ad_watch_log`、`ad_ecpm_record`、`ad_feed_reward_record`、`price_report`、`feedback`、`device_liveness`。 +- **18 张用户维度表 `.user_id` → `user.id`**:`coin_account`(同时是 PK)、`coin_transaction`、`cash_transaction`、`invite_cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`(同时是 PK)、`signin_record`、`user_task`、`comparison_record`(2026-07 起 `user_id` **可空**——harvest 帧0 建行时软鉴权可能拿不到)、`comparison_milestone_claim`、`savings_record`、`ad_reward_record`、`ad_watch_log`、`ad_ecpm_record`、`ad_feed_reward_record`、`price_report`、`feedback`、`device_liveness`。 - `admin_audit_log.admin_id` → `admin_user.id`。 - `price_report.comparison_record_id` → `comparison_record.id`(可空:关联记录被删后仍留上报历史)。 - **邀请两表** → `user.id`:`invite_relation.inviter_user_id`、`invite_relation.invitee_user_id`(唯一)、`invite_fingerprint.inviter_user_id`——注意 FK 列名是 `inviter`/`invitee_user_id`,不是 `user_id`。 @@ -187,7 +186,7 @@ | biz_type | ref_id 指向 | amount 符号 | |---|---|---| | `signin` | 当天日期串(= `signin_record.signin_date` 的 ISO `YYYY-MM-DD`) | + | - | `signin_boost` | 当天日期串(= `signin_boost_record.signin_date` 的 ISO `YYYY-MM-DD`) | + | + | `signin_boost`(**历史,2026-07 已下线**) | 当时的广告 `trans_id`,无则当天日期 ISO 串。不再产生新行,存量保留供对账 | + | | `task_` | `user_task.task_key` | + | | `reward_video` / `ad_reward`(历史) | `ad_reward_record.trans_id` | + | | `feed_ad_reward` | `ad_feed_reward_record.client_event_id` | + | @@ -225,7 +224,7 @@ user ─1:1─ coin_account user ─1:1─ wechat_transfer_authorization user ─1:N─ { coin_transaction, cash_transaction, invite_cash_transaction, withdraw_order, - signin_record, signin_boost_record, user_task, comparison_record(user_id 可空), + signin_record, user_task, comparison_record(user_id 可空), comparison_milestone_claim, savings_record, ad_reward_record, ad_watch_log, ad_ecpm_record, ad_feed_reward_record, price_report, feedback, device_liveness } (device_liveness 硬 FK; (user_id,device_id) 唯一) @@ -255,7 +254,7 @@ launch_confirm_sample (独立, 无硬 FK; 都上报不去 1. **余额快照** `coin_account`:`coin_balance`(金币个数)+ `cash_balance_cents`(现金分)+ `invite_cash_balance_cents`(邀请奖励金分,#82),一用户一行,读取展示用。 2. **流水账本** `coin_transaction` / `cash_transaction` / `invite_cash_transaction`:每次变动写一笔,`balance_after*` 记变动后余额,可逐笔回溯对账。**现金与邀请奖励金是两本物理隔离的账**——发放口径与提现对账各自独立。 -3. **唯一变动入口**:金币走 `repositories/wallet.grant_coins`,邀请奖励金走 `grant_invite_cash`——都是「更新快照 + 写流水,**不 commit**,由调用方同一事务 commit」。signin / signin_boost / task / ad_reward / feed_ad_reward / exchange / admin 走 `grant_coins`;`invite_reward` / admin 调整走 `grant_invite_cash`,靠 `biz_type` 区分来源。 +3. **唯一变动入口**:金币走 `repositories/wallet.grant_coins`,邀请奖励金走 `grant_invite_cash`——都是「更新快照 + 写流水,**不 commit**,由调用方同一事务 commit」。signin / task / ad_reward / feed_ad_reward / exchange / admin 走 `grant_coins`(`signin_boost` 2026-07 已下线,存量流水保留);`invite_reward` / admin 调整走 `grant_invite_cash`,靠 `biz_type` 区分来源。 - **汇率**:`10000 金币 = 1 元 = 100 分`(`rewards.COIN_PER_YUAN`);兑换额必须是整分倍数。 - **提现状态机**:`reviewing`(发起即原子扣款、待人工审核、**不打款**)→ 审核通过 `pending`(微信转账在途)→ `success` / `failed`(失败自动退款);审核拒绝 `rejected`(退款)。**按 `withdraw_order.source` 分账**(#121):`coin_cash` 单的扣款/退款写 `cash_transaction`,`invite_cash` 单写 `invite_cash_transaction`;`out_bill_no` 幂等,孤儿 pending 单由 `reconcile_pending_withdraws` 对账兜底,admin `withdraws/ledger-check` 分账校验「单 ↔ 流水」。 diff --git a/docs/database/README.md b/docs/database/README.md index 5f93769..ef191e2 100644 --- a/docs/database/README.md +++ b/docs/database/README.md @@ -35,7 +35,6 @@ | `withdraw_order` | 提现单(现金→微信零钱,含人工审核态;`source` 分账 coin_cash/invite_cash) | `models/wallet.py` | [详情](./withdraw_order.md) | | `wechat_transfer_authorization` | 微信免确认转账授权(一用户一行) | `models/wallet.py` | [详情](./wechat_transfer_authorization.md) | | `signin_record` | 签到记录(7 天循环) | `models/signin.py` | [详情](./signin_record.md) | -| `signin_boost_record` | 签到后看广告膨胀记录 | `models/signin.py` | [详情](./signin_boost_record.md) | | `user_task` | 一次性任务领取去重 | `models/task.py` | [详情](./user_task.md) | | `ad_reward_record` | 看激励视频发奖记录(S2S 回调,trans_id 幂等) | `models/ad_reward.py` | [详情](./ad_reward_record.md) | | `ad_watch_log` | 看广告观看时长(旧版兼容字段) | `models/ad_watch_log.py` | [详情](./ad_watch_log.md) | diff --git a/docs/database/ad_reward_record.md b/docs/database/ad_reward_record.md index dc6eaf9..c628e7a 100644 --- a/docs/database/ad_reward_record.md +++ b/docs/database/ad_reward_record.md @@ -2,7 +2,7 @@ > 模型 `app/models/ad_reward.py` · 仓库 `app/repositories/ad_reward.py` · 接口 [ad-pangle-callback](../api/ad-pangle-callback.md) / [ad-reward-status](../api/ad-reward-status.md) / [ad-test-grant](../api/ad-test-grant.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) -每条 = 穿山甲一次**服务端激励回调**。`trans_id` 唯一做幂等键(穿山甲会重试,同号只处理一次)。`reward_scene` 区分普通激励视频、签到膨胀等场景;`reward_date`(北京时间日期串)给普通激励视频"每日上限"计数用。 +每条 = 穿山甲一次**服务端激励回调**。`trans_id` 唯一做幂等键(穿山甲会重试,同号只处理一次)。`reward_scene` 区分普通激励视频、提现看视频等场景;`reward_date`(北京时间日期串)给普通激励视频"每日上限"计数用。 ## 用在哪 / 增删改查 - **C(插入)**:`POST /ad/pangle-callback`(穿山甲 S2S,经 SHA256 验签;`grant_ad_reward` 或场景业务处理)或 `POST /ad/test-grant`(本地联调)。普通激励视频三道闸:① 验签不过 → API 层 403,不进库;② `trans_id` 已存在 → 原样返回不重复发;③ **当日发奖次数(`DAILY_AD_REWARD_LIMIT`,默认 500)到顶** → 记一行 `status='capped'`、`coin=0`、不发币。否则按 eCPM 公式发币。另:`POST /ad/reward-noshow`(`record_reward_noshow`,Bearer)在用户提前关/未发奖时记一行 `status='closed_early'`、`coin=0` 留痕(同 session 已 granted 则跳过)。 @@ -13,15 +13,16 @@ | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| | `id` | Integer | PK, autoincrement | | -| `trans_id` | String(64) | UNIQUE, index, NOT NULL | 穿山甲交易号(幂等键)。**被 `coin_transaction.ref_id` 引用**(biz_type=reward_video/signin_boost 等)。`closed_early` 留痕记录无 S2S 交易号,用合成键 `noreward:{ad_session_id}` | +| `trans_id` | String(64) | UNIQUE, index, NOT NULL | 穿山甲交易号(幂等键)。**被 `coin_transaction.ref_id` 引用**(biz_type=reward_video 等)。`closed_early` 留痕记录无 S2S 交易号,用合成键 `noreward:{ad_session_id}` | | `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户(回调 media_extra 带回;不存在抛 UnknownUserError) | -| `reward_scene` | String(32) | NOT NULL, default `reward_video` | 奖励场景:`reward_video` 普通激励视频;`signin_boost` 签到膨胀 | +| `reward_scene` | String(32) | NOT NULL, default `reward_video` | 奖励场景:`reward_video` 普通激励视频(当前唯一发币场景);`withdrawal_ad` 提现门槛视频(只留痕不发币);`signin_boost` **历史值,2026-07 已下线** | | `ad_session_id` | String(64) | index, nullable | 客户端广告会话 ID,来自 `extra.ad_session_id`;用于匹配 `ad_ecpm_record` | +| `boost_round_id` | String(64) | nullable | 「这条广告属于哪一轮膨胀」,来自 `extra.boost_round_id`。一轮 = 用户点「去膨胀」到点「放弃赚钱」之间连看的若干条。**纯标签,不参与发奖判定**;仅供 `/ad/reward-result` 求和出 `round_coin`(弹窗显示的累计值)。老客户端 / extra 丢失时 NULL | | `ecpm_raw` | String(32) | nullable | 本次发奖采用的 eCPM 原始值;可来自 S2S `ecpm` 或客户端上报 | | `app_env` | String(16) | nullable | 来源应用 `prod`(傻瓜比价)/`test`(测试);S2S 不带,发奖时按 `ad_session_id` 匹配 `ad_ecpm_record` 回填,查不到 NULL。广告收益报表金币侧按它聚合 | | `our_code_id` | String(64) | nullable | 我们配置的代码位 104xxx(同上回填) | | `coin` | Integer | NOT NULL, default 0 | 实发金币;`capped`/`ecpm_missing`/`closed_early`/业务不满足时为 0 | -| `status` | String(16) | NOT NULL, default `granted` | 取值:`granted`(已发)/ `capped`(当日次数超限)/ `ecpm_missing`(缺 eCPM)/ `closed_early`(展示了但用户提前关/跳过,未发奖,客户端 reward-noshow 留痕)/ `not_signed`/`already_boosted`/`last_day`/ `unknown_scene`(回调 `reward_scene` 不在支持集合,只留痕不发) | +| `status` | String(16) | NOT NULL, default `granted` | 取值:`granted`(已发)/ `capped`(当日次数超限)/ `ecpm_missing`(缺 eCPM)/ `closed_early`(展示了但用户提前关/跳过,未发奖,客户端 reward-noshow 留痕)/ `unknown_scene`(回调 `reward_scene` 不在支持集合,只留痕不发) | | `reward_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它等值统计当日发奖次数 | | `reward_name` | String(64) | nullable | 穿山甲上报奖励名(参考,不作发奖依据) | | `raw` | String(1024) | nullable | 回调原始参数(审计排查) | @@ -34,8 +35,10 @@ ## 索引与约束 - PK `id`;UNIQUE+index `trans_id`;index `user_id`、`reward_date`、`created_at`、`ad_session_id`。 +- 复合 index `ix_ad_reward_user_boost_round` = `(user_id, boost_round_id)`:算「本轮累计已发」用。**求和恒带 `user_id`** —— `boost_round_id` 是客户端生成的,不带 `user_id` 等于让任何人拿别人的轮 id 查别人发了多少。 ## 注意 - 普通激励视频按 eCPM 公式发奖;若 S2S 与客户端会话上报都缺 eCPM,记录 `status='ecpm_missing'`、`coin=0`,不发币。 -- 签到膨胀复用本表记录 S2S 幂等,实发固定 `2000` 金币由 `signin_boost_record`/`coin_transaction.biz_type=signin_boost` 承载。 +- 签到膨胀(`reward_scene=signin_boost`)2026-07 已下线,存量行保留供对账;签到弹窗的「看广告膨胀」现与福利页看视频同走 `reward_video`(按 eCPM 公式发)。 +- **膨胀轮累计**:`SUM(coin) WHERE user_id=? AND boost_round_id=? AND status='granted'`,由 `/ad/reward-result` 返回为 `round_coin`。客户端就算一直复用同一个轮 id,也只是把展示数字滚大 —— 求和的是**已发生**的发奖记录,不产生任何新入账,无资损风险。 - 并发同 `trans_id` 撞唯一约束 → catch IntegrityError 回滚返回已存在那条(幂等兜底)。 diff --git a/docs/database/app_config.md b/docs/database/app_config.md index 95e7aac..dbe151f 100644 --- a/docs/database/app_config.md +++ b/docs/database/app_config.md @@ -14,7 +14,7 @@ ## 字段 | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| -| `key` | String(64) | **PK** | 配置标识,取值见 `config_schema.CONFIG_DEFS`:`signin_rewards` / `min_exchange_coin` / `withdraw_min_cents` / `withdraw_max_cents` / `task_rewards` / `record_milestones` / `ad_reward_coin` / `ad_daily_limit` / `ad_max_coin` / `ad_round_count` / `ad_cooldown_sec` / `signin_boost_coin` / `withdraw_auto_reconcile_enabled` / `comparing_ad_enabled` | +| `key` | String(64) | **PK** | 配置标识,取值见 `config_schema.CONFIG_DEFS`:`signin_rewards` / `min_exchange_coin` / `withdraw_min_cents` / `withdraw_max_cents` / `task_rewards` / `record_milestones` / `ad_reward_coin` / `ad_daily_limit` / `ad_max_coin` / `ad_round_count` / `ad_cooldown_sec` / `withdraw_auto_reconcile_enabled` / `comparing_ad_enabled` | | `value` | JSON(PG: JSONB) | NOT NULL | 配置值,类型随 key(`int` / `int_list` 如签到 14 档 / `dict_str_int` 如 task_rewards / `bool` 如 withdraw_auto_reconcile_enabled / comparing_ad_enabled) | | `updated_by_admin_id` | Integer | nullable | 最后修改的管理员 id(= `admin_user.id`,软引用,无 FK) | | `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 最后修改时间 | @@ -29,3 +29,12 @@ ## 注意 - 不缓存:配置读频率低(每次福利操作读一次,主键查极快),admin 改了立即生效、跨进程一致(多 worker 也对)。 - 新增可配项 = 在 `CONFIG_DEFS` 加一条 + 业务处改用 `app_config.get_value(db, key)` 读;不需要建迁移(行是动态插的,表结构不变)。 + +## 专用 key(借表不进 CONFIG_DEFS) +有自己的语义与专用管理页的配置,复用本表但**不注册进 `CONFIG_DEFS`**——混进通用「系统配置」页只会显示成一个没头没尾的 on/off。它们各有一对 `get_*` / `set_*` 函数(仍在 `repositories/app_config.py`),`value` 存 dict,空行回退各自的模块内默认值。 + +| key | 管理页 / admin 端点 | C 端读取 | 说明 | +|---|---|---|---| +| `ad_config` | `GET/PATCH /admin/api/ad-config` | `GET /api/v1/platform/ad-config`(去密钥) | 穿山甲 app_id / 各代码位 / 各场景开关 | +| `app_version` | 内部写入(`X-Internal-Secret`) | `GET /api/v1/platform/app-version` | OTA 最新版本信息 | +| `huawei_review` | `GET/PATCH /admin/api/huawei-review` | `GET /api/v1/platform/huawei-review` | 华为审核开关:`{"mode": "default"|"review"}`,决定新手引导「快速设置」权限步能否被用户关闭。脏值/空行一律回退 `default`(不给退出按钮) | diff --git a/docs/database/coin_transaction.md b/docs/database/coin_transaction.md index f29958d..a2778a2 100644 --- a/docs/database/coin_transaction.md +++ b/docs/database/coin_transaction.md @@ -10,7 +10,7 @@ | 动作 / endpoint | `biz_type` | `amount` | `ref_id` 指向 | |---|---|---|---| | 签到 `POST /signin/do` | `signin` | + | 当天日期串(= `signin_record.signin_date` ISO) | - | 签到后看广告膨胀 `POST /signin/boost` | `signin_boost` | + | 广告 `trans_id`(= `signin_boost_record.ad_ref_id`);无 ad_ref_id 时回退当天日期 ISO 串 | + | ~~签到后看广告膨胀~~(**2026-07 已下线**) | `signin_boost` | + | 历史行:当时的广告 `trans_id`,无则当天日期 ISO 串。不再产生新行;签到弹窗的看广告改走 `reward_video` | | 领任务 `POST /tasks/claim` | `task_`(如 `task_enable_notification`) | + | 一次性任务=`user_task.task_key`;可重复任务(`enable_notification`)=带序号 `task_key:N` | | 普通激励视频 S2S 回调 `POST /ad/pangle-callback` | `reward_video`(历史兼容:`ad_reward`) | + | `ad_reward_record.trans_id` | | 信息流广告结算 `POST /ad/feed-reward` | `feed_ad_reward` | + | `ad_feed_reward_record.client_event_id` | @@ -36,7 +36,7 @@ ## 关系 / Join Key - `user_id` → `user.id`(多对一)。 -- `ref_id` 是**软关联**(无 FK),目标随 `biz_type`:`signin`→签到日(`signin_record.signin_date` ISO) / `signin_boost`→`signin_boost_record.ad_ref_id`(无则当天日期) / `task_`→一次性任务=`user_task.task_key`、可重复任务=`task_key:N` / `reward_video`/`ad_reward`→`ad_reward_record.trans_id` / `feed_ad_reward`→`ad_feed_reward_record.client_event_id` / 其余 null。 +- `ref_id` 是**软关联**(无 FK),目标随 `biz_type`:`signin`→签到日(`signin_record.signin_date` ISO) / `signin_boost`(历史)→当时的广告 `trans_id`(无则当天日期) / `task_`→一次性任务=`user_task.task_key`、可重复任务=`task_key:N` / `reward_video`/`ad_reward`→`ad_reward_record.trans_id` / `feed_ad_reward`→`ad_feed_reward_record.client_event_id` / 其余 null。 ## 索引与约束 - PK `id`;index `user_id`、`created_at`。 diff --git a/docs/database/signin_boost_record.md b/docs/database/signin_boost_record.md deleted file mode 100644 index ea71341..0000000 --- a/docs/database/signin_boost_record.md +++ /dev/null @@ -1,22 +0,0 @@ -# signin_boost_record — 签到膨胀记录 - -App 用户当天签到后,看完激励视频可固定膨胀一次(默认 3000 金币,`rewards.SIGNIN_BOOST_COIN`,运营后台 `app_config.signin_boost_coin` 可改)。循环最后一天(`cycle_day == SIGNIN_CYCLE_LEN`,即 7 天循环的第 7 天)不展示也不允许膨胀。本表记录膨胀动作,并用唯一约束防重复补发。 - -## 字段 - -| 字段 | 类型 | 约束 | 说明 | -|---|---|---|---| -| `id` | Integer | PK | 自增主键 | -| `user_id` | Integer | FK → `user.id`, index, NOT NULL | 用户 | -| `signin_date` | Date | NOT NULL | 被膨胀的签到日期,北京时间 | -| `coin_awarded` | Integer | NOT NULL | 本次补发金币,默认固定 3000(`rewards.get_signin_boost_coin`) | -| `ad_ref_id` | String(64) | nullable | 穿山甲 S2S 回调 `trans_id` | -| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 创建时间 | - -## 约束 - -- `UNIQUE(user_id, signin_date)` = `uq_signin_boost_user_date`:同一用户同一天只能膨胀一次。 - -## 关联 - -- 膨胀成功时写 `coin_transaction.biz_type=signin_boost`,`ref_id = ad_ref_id`(无 ad_ref_id 时回退当天日期 ISO 串)。 diff --git a/docs/database/user.md b/docs/database/user.md index ce33696..d6b0d7e 100644 --- a/docs/database/user.md +++ b/docs/database/user.md @@ -29,7 +29,7 @@ | `last_login_at` | DateTime(tz) | 应用层 default utcnow | 最近登录时间(每次登录更新) | ## 关系 / Join Key -- **被引用方(本表是 1,对方是 N/1)**:`coin_account`、`coin_transaction`、`cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`、`signin_record`、`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`、`price_report`、`feedback` 的 `user_id` 均 → `user.id`;`invite_relation` 的 `inviter_user_id` / `invitee_user_id` 均 → `user.id`。 +- **被引用方(本表是 1,对方是 N/1)**:`coin_account`、`coin_transaction`、`cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`、`signin_record`、`user_task`、`comparison_record`、`comparison_milestone_claim`、`savings_record`、`ad_reward_record`、`ad_watch_log`、`ad_ecpm_record`、`ad_feed_reward_record`、`price_report`、`feedback` 的 `user_id` 均 → `user.id`;`invite_relation` 的 `inviter_user_id` / `invitee_user_id` 均 → `user.id`。 - 与 `admin_user` **无任何关联**(C 端用户 vs 后台管理员,两套体系)。 ## 索引与约束 diff --git a/docs/database/数据表字典-产品参考.md b/docs/database/数据表字典-产品参考.md index fa59348..822d4c7 100644 --- a/docs/database/数据表字典-产品参考.md +++ b/docs/database/数据表字典-产品参考.md @@ -27,7 +27,6 @@ | 钱包 | `withdraw_order` | 提现单 | 现金→微信零钱提现 | | 钱包 | `wechat_transfer_authorization` | 微信转账免确认授权 | 用户授权后转账免逐笔确认 | | 激励 | `signin_record` | 签到记录 | 每日签到 | -| 激励 | `signin_boost_record` | 签到膨胀记录 | 签到后看广告翻倍补发 | | 激励 | `user_task` | 一次性任务完成 | 只能领一次的任务 | | 激励 | `comparison_milestone_claim` | 比价战绩领取 | 比价次数里程碑奖励 | | 比价 | `comparison_record` | 比价记录 | 用户视角「我的比价记录」 | @@ -181,18 +180,6 @@ App 用户主表。两种登录(极光一键 / 短信验证码)都映射到 | coin_awarded | 整数 | 本次发放金币 | | created_at | 时间 | 时间 | -## `signin_boost_record` — 签到膨胀记录 - -签到后看广告「膨胀」翻倍,一天最多一次,补发金额=当天签到原始奖励。 - -| 字段 | 类型 | 说明 | -|---|---|---| -| id | 整数 | **主键** | -| user_id | 整数 | **外键→user** | -| signin_date | 日期 | 与 user_id 组成**唯一**(防并发重复补发) | -| coin_awarded | 整数 | 补发金币 | -| ad_ref_id | 字符串 | 广告会话/交易号,可空 | -| created_at | 时间 | 时间 | ## `user_task` — 一次性任务完成记录 @@ -379,7 +366,7 @@ profile「累计帮你省了」「省钱战绩」的唯一数据源。(user_id, | user_id | 整数 | **外键→user** | | coin | 整数 | 实发金币(超限为 0) | | status | 字符串 | granted(已发)/ capped(当日超限)/ ecpm_missing(缺 eCPM) | -| reward_scene | 字符串 | reward_video(福利页看视频)/ signin_boost(签到膨胀) | +| reward_scene | 字符串 | reward_video(福利页看视频,当前唯一发币场景)/ withdrawal_ad(提现门槛视频,不发币)/ signin_boost(**历史值,2026-07 已下线**) | | ad_session_id | 字符串 | 广告会话 id,可空 | | ecpm_raw | 字符串 | 本次发奖采用的 eCPM 原始值,可空 | | app_env | 字符串 | 应用环境 prod/test(回填),可空 | diff --git a/docs/guides/看广告赚金币上线清单.md b/docs/guides/看广告赚金币上线清单.md index 2ef65a4..04efae1 100644 --- a/docs/guides/看广告赚金币上线清单.md +++ b/docs/guides/看广告赚金币上线清单.md @@ -70,7 +70,7 @@ ## C. 部署 + 包名 - [ ] **后端部署到公网**(由服务器管理员;`/opt/shaguabijia-app-server`,uvicorn 127.0.0.1:8770,nginx 反代) -- [ ] **跑迁移**:`alembic upgrade head`(包含 `ad_reward_record`、`signin_boost_record`、`ad_feed_reward_record` 等表) +- [ ] **跑迁移**:`alembic upgrade head`(包含 `ad_reward_record`、`ad_feed_reward_record` 等表) - [ ] **包名定稿**:当前 `com.jishisongfu.shaguabijia`。穿山甲(APP_ID 5830519)、极光、微信都绑"包名 + 签名",定了再上,别再换 - 微信提现链路当前因复用 elderhelper 的 appid + 包名切换已 dead,要恢复需申请傻瓜比价自己的微信 appid(另见客户端 build.gradle 注释) - [ ] (可选,提升真实填充)集成 **MSA OAID SDK**:申请证书(绑包名、审核几天)。App 侧当前 `getDevOaid=null`,有 OAID 后投放匹配 + 填充会明显改善 diff --git a/docs/superpowers/specs/2026-07-16-inactivity-reset-design.md b/docs/superpowers/specs/2026-07-16-inactivity-reset-design.md index 98de060..62df6a5 100644 --- a/docs/superpowers/specs/2026-07-16-inactivity-reset-design.md +++ b/docs/superpowers/specs/2026-07-16-inactivity-reset-design.md @@ -38,8 +38,8 @@ | 决策点 | 结论 | 理由 | |---|---|---| -| **活跃口径** | 与"用户管理"一致:`max(首页可见 show/home, 比价, 领券)`,**不含 last_login_at**;无任何信号时以 `created_at` 为非空基线 | 比价可从**浮窗**触发、不进首页;`last_login_at` 只在登录/换绑动作更新(re-login 也算),代表不了"在用 App",故彻底排除 | -| **"进首页"信号落地** | **方案 A:前端上报 `home_view` 埋点**(复用 `/analytics/events`),非新接口 | 三个活跃信号统一为同类埋点事件;零新接口零新列;与 admin 口径天然一致。B(鉴权接口 + 列)"更权威"的优势是假的——比价/领券仍是端上报事件,最弱环决定整体可信度 | +| **活跃口径** | 与"用户管理"一致:`max(首页可见 home_visible, 比价, 领券)`,**不含 last_login_at**;无任何信号时以 `created_at` 为非空基线 | 比价可从**浮窗**触发、不进首页;`last_login_at` 只在登录/换绑动作更新(re-login 也算),代表不了"在用 App",故彻底排除 | +| **"进首页"信号落地** | **方案 A:前端上报 `home_visible` 埋点**(复用 `/analytics/events`),非新接口 | 三个活跃信号统一为同类埋点事件;零新接口零新列;与 admin 口径天然一致。B(鉴权接口 + 列)"更权威"的优势是假的——比价/领券仍是端上报事件,最弱环决定整体可信度 | | **清零范围** | **金币 + 折算现金**(**邀请现金不清**——产品红线,仅快照入审计) | 对应"账户里的金币和现金";邀请奖励金与金币现金物理隔离、不可累加,见 `wallet.CoinAccount` 注释 | | **预警推送** | **可插拔通知器 + 日志占位**(v1),后续接 JPush/短信 | 现状无真实推送能力;先把清零主流程 + 审计做扎实,不阻塞 | | **预警时机** | **完全可配置**(提前天数列表 + 次数 + 执行点 + 通道) | R5 | @@ -76,7 +76,7 @@ last_active = max( ### 模块内容 - 常量: - - **首页可见活跃信号已定名:`event=show` + `page=home`**(前端确认,原占位 `home_view`;下文出现的 `home_view` 均指此信号)。活跃行为过滤见 `activity.active_event_condition()`:首页可见 ∪ 比价 `real_compare_start` ∪ 领券 `real_coupon_start`;`ACTIVE_EVENTS` 仅含后两个纯 event 名(首页可见是 event+page 组合、单列)。 + - **首页可见活跃信号已定名:`event=home_visible`**(前端最终确认;曾用过渡期 `show`+`page=home` 组合,已废弃)。活跃行为过滤见 `activity.active_event_condition()`:首页可见 `home_visible` ∪ 比价 `real_compare_start` ∪ 领券 `real_coupon_start`——三者均为纯 event 名,全部收进 `ACTIVE_EVENTS`。 - `ACTIVE_ENGAGE_TYPE = "claim_started"` - `last_active_subqueries(db)` —— 复刻现 admin `queries._last_active_parts()`:两个按 `user_id` 的 `GROUP BY max(created_at)` 聚合子查询。 - `last_active_expr(base_col, ev_sub, eng_sub, dialect)` —— 生成 `greatest`/`max`(PG `func.greatest`/SQLite `func.max`);子聚合缺失时 `coalesce(子聚合, User.created_at)` 兜底(注册基线恒非空,**替代原 last_login_at**)。 @@ -210,7 +210,7 @@ INACTIVITY_RESET_CHECK_INTERVAL_SEC = 1800 # worker 唤醒间隔(可复用现 ## 9. 幂等与重新活跃 -- **重新活跃自动退出**:`inactive_days` 由 §4 口径**实时算**。用户一有 `home_view`/比价/领券(**登录本身不算**),`last_active` 前移,自动移出预警与清零队列。**无需**显式"重置标记"。 +- **重新活跃自动退出**:`inactive_days` 由 §4 口径**实时算**。用户一有 `home_visible`/比价/领券(**登录本身不算**),`last_active` 前移,自动移出预警与清零队列。**无需**显式"重置标记"。 - **预警去重**:`inactivity_notification_log` 中存在 `stage==k 且 created_at > last_active` 的行 ⟹ 本 streak 已推过档 `k`,不重推。用户回归后 `last_active` 前移,旧预警行自然"失效",开启新 streak。 - **清零幂等**:阶段 B 只处理三桶非全 0 者;清完 = 0,次日不再匹配。worker 重启 / 多次唤醒 / 补跑均安全,不产生重复清零或重复流水。 - **稳健补发**:worker 漏跑数天后,某用户可能同时满足多档;只补发**最紧急的未推档**(最小 `k`),避免一次刷屏。 @@ -221,7 +221,7 @@ INACTIVITY_RESET_CHECK_INTERVAL_SEC = 1800 # worker 唤醒间隔(可复用现 | 场景 | 处理 | |---|---| -| 新用户 | `created_at` 作活跃基线(恒非空)→ 注册即"第 1 日活跃";注册后连续 15 天无 home_view/比价/领券 才清 | +| 新用户 | `created_at` 作活跃基线(恒非空)→ 注册即"第 1 日活跃";注册后连续 15 天无 home_visible/比价/领券 才清 | | 在途提现 | 提现申请时现金已扣入 `WithdrawOrder`,当前余额已不含在途;只清当前余额、不动提现单。提现失败退款到已清账户 = 用户的钱,正常 | | 与 `daily_auto_exchange` 并存 | 各自逐用户幂等;金币多已日结折现金,三桶全清正好覆盖 | | 时区/日界 | 统一北京(`rewards.cn_today()`/`CN_TZ`);**清零/预警按北京自然日 0 点对齐**(末次活跃记为第 1 日 → 第 16 日 0 点清零,见 §4),非滚动 24h;流水 `created_at` 沿用北京 wall-clock naive | @@ -229,11 +229,11 @@ INACTIVITY_RESET_CHECK_INTERVAL_SEC = 1800 # worker 唤醒间隔(可复用现 --- -## 11. 前端依赖:`home_view` 埋点(跨仓 — Android) +## 11. 前端依赖:`home_visible` 埋点(跨仓 — Android) -- **Android 端**(`shaguabijia-app-android`)需在**首页可见**(`onResume`/Tab 切入)时,向现有 `POST /api/v1/analytics/events` 批量上报里加一条 `event=<首页可见事件名>`(名称明天加埋点时定,暂记 `"home_view"`) 的事件,**携带登录后的 `user_id`**。 +- **Android 端**(`shaguabijia-app-android`)需在**首页可见**(`onResume`/Tab 切入)时,向现有 `POST /api/v1/analytics/events` 批量上报里加一条 `event=home_visible`(前端已定名)的事件,**携带登录后的 `user_id`**。 - 客户端按会话/前台去重即可(服务端只取 `max(created_at)`,多报无害)。 -- **上线顺序依赖**:`home_view` 全量覆盖前,"进首页"信号缺失,只有比价/领券能推进活跃、其余落到 `created_at` 基线("只开首页不操作"且注册满 15 天的用户会被误清)—— 故**开真清(`ENABLED=true`)必须待 `home_view` 铺满后再开**(§13);dry-run 只记名单不动钱、可先开着看。 +- **上线顺序依赖**:`home_visible` 全量覆盖前,"进首页"信号缺失,只有比价/领券能推进活跃、其余落到 `created_at` 基线("只开首页不操作"且注册满 15 天的用户会被误清)—— 故**开真清(`ENABLED=true`)必须待 `home_visible` 铺满后再开**(§13);dry-run 只记名单不动钱、可先开着看。 --- @@ -241,25 +241,25 @@ INACTIVITY_RESET_CHECK_INTERVAL_SEC = 1800 # worker 唤醒间隔(可复用现 - `app/admin/repositories/queries.py`:删本地 `_ACTIVE_EVENTS`/`_last_active_parts()`,改用 `activity.py` 的常量与子查询构造;`list_users` 的 `greatest(...)` 排序/筛选、`_attach_last_active` 均改走共享构造器。 - `app/admin/repositories/stats.py`:`COMPARE_START_EVENT`/`COUPON_START_EVENT`/活跃用户集(`:138-146`)改用共享常量与口径。 -- **行为变化(预期内、需产品知会)**:admin 的"最近活跃 / DAU"口径变化——**移除 `last_login_at`(登录不再计为活跃)、以 `created_at` 为基线、纳入 `home_view`**。net:`home_view` 铺满后更准(真正把"开首页"算进活跃);铺满前"只登录不操作"的用户活跃度会下降。 -- **回归底线**:现有 admin 用户列表 / stats 测试按新口径**更新预期**(last_login_at 移除 + created_at 基线 + home_view 纳入);非活跃口径部分行为不变。 +- **行为变化(预期内、需产品知会)**:admin 的"最近活跃 / DAU"口径变化——**移除 `last_login_at`(登录不再计为活跃)、以 `created_at` 为基线、纳入 `home_visible`**。net:`home_visible` 铺满后更准(真正把"开首页"算进活跃);铺满前"只登录不操作"的用户活跃度会下降。 +- **回归底线**:现有 admin 用户列表 / stats 测试按新口径**更新预期**(last_login_at 移除 + created_at 基线 + home_visible 纳入);非活跃口径部分行为不变。 --- ## 13. 灰度与上线顺序(安全优先) 1. **后端先行**:合入共享模块 + 两表 + worker + 通知器,`INACTIVITY_RESET_ENABLED=False`;活跃口径以 `created_at` 为非空基线、**不含 last_login_at**。 -2. **Android 发版**:上报 `home_view`;观察 analytics 覆盖率。 +2. **Android 发版**:上报 `home_visible`;观察 analytics 覆盖率。 3. **dry-run 灰度(默认即是)**:`INACTIVITY_RESET_ENABLED=False` 时 worker 常驻只写审计名单(`reason=inactive_Nd_dryrun`)、不动钱、不预警;核对名单准确。 4. **开真清**:确认无误后置 `INACTIVITY_RESET_ENABLED=True`(转为真清 + 预警)。 -5. **收尾/监控**:持续观察 `home_view` 覆盖率与预警/清零名单;发现"活跃却被判不活跃"的漏报即回查埋点覆盖(口径已不含 last_login_at,登录不再兜底)。 +5. **收尾/监控**:持续观察 `home_visible` 覆盖率与预警/清零名单;发现"活跃却被判不活跃"的漏报即回查埋点覆盖(口径已不含 last_login_at,登录不再兜底)。 --- ## 14. 测试计划 -- **活跃口径(共享模块)**:`home_view`/比价/领券 各单独命中都算活跃;**纯登录不算**;无信号用户以 `created_at` 计;`max` 取最新;naive/aware 混算不崩。 -- **admin 回归**:用户列表 / stats 按新口径更新预期(移除 last_login_at + created_at 基线 + home_view)。 +- **活跃口径(共享模块)**:`home_visible`/比价/领券 各单独命中都算活跃;**纯登录不算**;无信号用户以 `created_at` 计;`max` 取最新;naive/aware 混算不崩。 +- **admin 回归**:用户列表 / stats 按新口径更新预期(移除 last_login_at + created_at 基线 + home_visible)。 - **不活跃判定**:`last_active` 分别 `<15d / =15d / >15d` × 有/无余额 的命中矩阵。 - **清零**:三桶归零;`inactivity_reset_log` 清前值正确;三条流水 `biz_type=inactivity_reset`、`balance_after=0`、`ref_id=log.id`;`total_coin_earned` 不变。 - **预警**:命中窗口调 notifier + 写 `notification_log`;同 streak 不重推;回归后 `last_active` 前移可再次预警;漏跑补发最紧急档。 diff --git a/scripts/reset_onboarding.py b/scripts/reset_onboarding.py new file mode 100644 index 0000000..0837543 --- /dev/null +++ b/scripts/reset_onboarding.py @@ -0,0 +1,138 @@ +"""重置指定用户的新手引导完成标记,让这个账号重新进新手引导页,方便反复测试引导流程。 + +原理:是否跳过引导只由 onboarding_completion 表里 (user_id, device_id) 那一行决定 +(见 app/models/onboarding.py)。删掉该用户的行 → 登录响应 onboarding_completed=false、 +GET /api/v1/user/onboarding/status 也返 false → 客户端下次登录/启动重走引导。 +本地 SharedPreferences 标记卸载即丢、以后端为准,所以删这一行就够,不用重装 App。 + +默认删该用户**所有设备**的记录(换机/多设备一起放开);只想放开某一台用 --device-id +(device_id = 客户端硬件级 ANDROID_ID,与登录 / onboarding/complete 传的是同一个值)。 + +与已有两个入口的分工: + - admin「设备维度引导管理」按**设备**重置(该设备上所有账号一起),本脚本按**账号**; + - POST /api/v1/user/onboarding/reset 要客户端自己带 device_id 调,本脚本从库里反查设备。 + +用法(在项目根、已 pip install -e . 的环境里跑): + python scripts/reset_onboarding.py # 默认测试号 11111111111 + python scripts/reset_onboarding.py 13800138000 # 指定手机号 + python scripts/reset_onboarding.py --user-id 5 # 直接指定 user_id + python scripts/reset_onboarding.py --dry-run # 预览(照常执行再回滚),不落库 + python scripts/reset_onboarding.py --device-id abc123 # 只放开这一台设备,其余设备照旧跳过 + +走 SessionLocal 连 DATABASE_URL(SQLite / Postgres 都行),因此**默认只允许 APP_ENV=dev 改库** +(--dry-run 只读,任何环境都能跑)。线上确实要给某个用户开引导时加 --force —— 这张表只存 +"引导走过没"的标记,删了最坏结果是用户多看一次引导,不涉及金额/账目。 +""" +from __future__ import annotations + +import argparse +import sys + +from sqlalchemy import delete, select + +from app.core.config import settings +from app.db.session import SessionLocal, engine +from app.models.onboarding import OnboardingCompletion +from app.models.user import User + +# Windows 控制台默认 GBK,强制 UTF-8 否则中文输出乱码。stderr 也要设: +# SystemExit(如"用户不存在")的中文提示走的是 stderr。 +for _stream in (sys.stdout, sys.stderr): + if hasattr(_stream, "reconfigure"): + _stream.reconfigure(encoding="utf-8") + +# dev 下 engine 是 echo=True(APP_DEBUG),几十行 SQL 会把前后对比刷没。echo 走 SQLAlchemy 自己的 +# InstanceLogger,不吃 logging.setLevel,只能改 engine.echo。 +engine.echo = False + +DEFAULT_PHONE = "11111111111" + + +def resolve_user(db, phone: str, user_id: int | None) -> User: + if user_id is not None: + user = db.get(User, user_id) + if user is None: + raise SystemExit(f"user_id={user_id} 不存在") + return user + user = db.execute(select(User).where(User.phone == phone)).scalar_one_or_none() + if user is None: + raise SystemExit(f"手机号 {phone} 没有对应用户(注意 phone 才是登录账号,username 是展示 ID)") + return user + + +def print_state(db, user: User, device_id: str | None, label: str) -> None: + """打印该用户当前的引导完成标记。--device-id 时只看那一台,便于确认没误伤别的设备。""" + stmt = ( + select(OnboardingCompletion.device_id, OnboardingCompletion.completed_at) + .where(OnboardingCompletion.user_id == user.id) + .order_by(OnboardingCompletion.completed_at.desc()) + ) + if device_id: + stmt = stmt.where(OnboardingCompletion.device_id == device_id) + rows = db.execute(stmt).all() + + print(f"--- {label} ---") + scope = f"device_id={device_id}" if device_id else "全部设备" + if not rows: + print(f" onboarding_completion({scope}): (无) → 该用户会走引导") + return + print(f" onboarding_completion({scope}): {len(rows)} 条 → 这些设备上会跳过引导") + for did, at in rows: + print(f" device_id={did} 完成于 {at}") + + +def main() -> None: + parser = argparse.ArgumentParser(description="重置指定用户的新手引导,让其重新进引导页") + parser.add_argument("phone", nargs="?", default=DEFAULT_PHONE, + help=f"手机号(默认 {DEFAULT_PHONE})") + parser.add_argument("--user-id", type=int, default=None, help="直接按 user_id 定位,优先于 phone") + parser.add_argument("--device-id", default=None, + help="只重置这一台设备(硬件级 ANDROID_ID);默认重置该用户所有设备") + parser.add_argument("--dry-run", action="store_true", help="预览,最后回滚不落库") + parser.add_argument("--force", action="store_true", help="非 dev 环境也允许改库(仅删引导标记,不涉及账目)") + args = parser.parse_args() + + if not args.dry_run and settings.APP_ENV != "dev" and not args.force: + raise SystemExit( + f"APP_ENV={settings.APP_ENV},默认只有 dev 能改库。确认要在该环境重置请加 --force" + "(--dry-run 只读,任意环境可跑)" + ) + + db = SessionLocal() + try: + user = resolve_user(db, args.phone, args.user_id) + print(f"DB: {settings.DATABASE_URL} APP_ENV: {settings.APP_ENV}") + print(f"用户: id={user.id} phone={user.phone} username={user.username}") + print_state(db, user, args.device_id, "before") + + stmt = delete(OnboardingCompletion).where(OnboardingCompletion.user_id == user.id) + if args.device_id: + stmt = stmt.where(OnboardingCompletion.device_id == args.device_id) + deleted = db.execute(stmt).rowcount + + if not deleted: + # 没记录本来就会走引导 —— 常见于:换了新设备、或运营/客户端已经重置过一次。 + print("该用户(该范围内)本来就没有完成标记,已经会走引导了,无需处理。") + db.rollback() + else: + print_state(db, user, args.device_id, "after") + if args.dry_run: + db.rollback() + print(f"(dry-run:以上 after 为预览,已回滚,库没动;真跑会删 {deleted} 条)") + return + db.commit() + print(f"完成:删掉 {deleted} 条完成标记,{user.phone} 下次登录会重走新手引导。") + + # 测试号无论库里有没有记录都恒走引导(见 app/core/test_account.py),提醒一句免得白跑 + if settings.test_account_phone and user.phone == settings.test_account_phone: + print(f"提示:{user.phone} 是配置的测试账号(TEST_ACCOUNT_PHONE)," + "登录响应 onboarding_completed 恒为 false、本就每次都走引导,无需重置。") + + print("提醒:客户端是在登录响应 / 启动时查 onboarding/status 的,已经在首页的 App 不会自动跳转," + "退出登录重进(或杀掉重开)才会看到引导页。") + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/reset_signin_today.py b/scripts/reset_signin_today.py new file mode 100644 index 0000000..afff914 --- /dev/null +++ b/scripts/reset_signin_today.py @@ -0,0 +1,218 @@ +"""撤销指定用户「今天已签到」的状态,让今天可以重新签到,方便反复测试签到流程。 + +与 reset_signin.py 的区别:那个删**全部**签到历史(连续天数从头再来);本脚本只精确撤销 +**今天**这一次,昨天及以前的记录原样保留 —— 所以重签后 cycle_day / streak 会接着昨天继续, +7 天循环的档位不会被打乱,可以连着好几天测「第 N 档」的奖励。 + +默认是**完整撤销**(等于今天这次签到从没发生过): + 1. 删 signin_record 今天这行 → 今天变回未签到 + 2. 删今天的 signin 金币流水,并把金币从 coin_account 余额 / 累计收益里扣回 + +金币默认要退:签到流水**没有**唯一索引拦重复(ux_coin_transaction_task_ref 只覆盖 +biz_type LIKE 'task%'),不退的话每测一轮余额就白涨一次奖励,coin_transaction 里还会堆出 +同一 ref_id(日期)的重复流水,收益明细页会看到两条今天的签到。真想留着奖励用 --keep-coins。 + +例外:签到的金币若已被兑换成现金(余额已不够退),**自动跳过退款**并保留今天的签到流水。 +因为 coin_balance 必须恒等于流水总和,硬退会把余额退成负数 —— 夹到 0 又会吃掉别处赚的金币, +两种做法都会让账对不上。这时重签会再发一次奖励,余额多涨一档,属可接受的测试噪音。 + +用法(在项目根、已 pip install -e . 的环境里跑): + python scripts/reset_signin_today.py # 默认测试号 11111111111 + python scripts/reset_signin_today.py 13800138000 # 指定手机号 + python scripts/reset_signin_today.py --user-id 5 # 直接指定 user_id + python scripts/reset_signin_today.py --dry-run # 预览(照常执行再回滚),不落库 + python scripts/reset_signin_today.py --keep-coins # 只删签到记录,保留已发金币 + +「今天」直接复用 app.core.rewards.cn_today(北京时间),与签到判重同源,不自己算时区。 +走 SessionLocal 连 DATABASE_URL(SQLite / Postgres 都行),因此**只允许 APP_ENV=dev 时改库** +(--dry-run 只读,任何环境都能跑)。 +""" +from __future__ import annotations + +import argparse +import sys + +from sqlalchemy import select + +from app.core.config import settings +from app.core.rewards import cn_today +from app.db.session import SessionLocal, engine +from app.models.signin import SigninRecord +from app.models.user import User +from app.models.wallet import CoinAccount, CoinTransaction +from app.repositories import signin as crud_signin + +# Windows 控制台默认 GBK,强制 UTF-8 否则中文输出乱码 +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + +# dev 下 engine 是 echo=True(APP_DEBUG),几十行 SQL 会把前后对比刷没。echo 走 SQLAlchemy 自己的 +# InstanceLogger,不吃 logging.setLevel,只能改 engine.echo。 +engine.echo = False + +DEFAULT_PHONE = "11111111111" + + +def resolve_user(db, phone: str, user_id: int | None) -> User: + if user_id is not None: + user = db.get(User, user_id) + if user is None: + raise SystemExit(f"user_id={user_id} 不存在") + return user + user = db.execute(select(User).where(User.phone == phone)).scalar_one_or_none() + if user is None: + raise SystemExit(f"手机号 {phone} 没有对应用户(注意 phone 才是登录账号,username 是展示 ID)") + return user + + + +def print_state(db, user: User, today, label: str) -> None: + print(f"--- {label} ---") + rec = db.execute( + select(SigninRecord).where( + SigninRecord.user_id == user.id, SigninRecord.signin_date == today + ) + ).scalar_one_or_none() + if rec is None: + print(f" signin_record {today}: (无)") + else: + print(f" signin_record {today}: 第{rec.cycle_day}档 连续{rec.streak}天 +{rec.coin_awarded}金币") + + last = db.execute( + select(SigninRecord.signin_date) + .where(SigninRecord.user_id == user.id) + .order_by(SigninRecord.signin_date.desc()) + .limit(1) + ).scalar_one_or_none() + print(f" 最近一次签到: {last or '(从未签到)'}") + + + rows = db.execute( + select(CoinTransaction).where( + CoinTransaction.user_id == user.id, + CoinTransaction.biz_type == "signin", + CoinTransaction.ref_id == today.isoformat(), + ) + ).scalars().all() + print(f" coin_transaction(signin, 今天): {len(rows)} 条 / {sum(r.amount for r in rows)} 金币") + + acc = db.get(CoinAccount, user.id) + if acc is None: + print(" coin_account: (无)") + else: + print(f" coin_account: coin={acc.coin_balance} earned={acc.total_coin_earned}") + + # 用 App 自己的 get_status 复核,而不是脚本里重算一遍规则 —— 这行就是客户端会看到的 + st = crud_signin.get_status(db, user.id) + print(f" [签到接口] can_claim={st.can_claim} today_signed={st.today_signed} " + f"今天第{st.today_cycle_day}档({st.today_coin}金币) 已连续{st.consecutive_days}天") + + +def refund_today(db, user_id: int, today) -> None: + """退回今天签到(含膨胀)发的金币:删流水 + 扣余额。 + + 不变量:coin_balance 必须恒等于流水总和。所以余额不够退时**整笔跳过**,而不是硬退成 + 负数、或夹到 0 —— 夹到 0 会吃掉用户在别处赚的金币,两种做法都会让余额和流水对不上。 + """ + rows = list(db.execute( + select(CoinTransaction).where( + CoinTransaction.user_id == user_id, + CoinTransaction.biz_type == "signin", + CoinTransaction.ref_id == today.isoformat(), + ) + ).scalars().all()) + if not rows: + return + acc = db.get(CoinAccount, user_id) + if acc is None: + return + + # 从最近一笔往回退,退到余额兜不住为止:正常情况下今天只有一笔,整笔退掉 = 干净的撤销。 + # 少数情况今天堆了多笔(上一轮测试时金币已被兑换、退不掉而留下的),这样也能保证 + # 「本轮新发的那笔」一定被退掉 —— 否则每测一轮余额就永久多涨一档。 + rows.sort(key=lambda r: r.id, reverse=True) + refundable: list[CoinTransaction] = [] + total = 0 + for r in rows: + if total + r.amount > acc.coin_balance: + break + refundable.append(r) + total += r.amount + + for r in refundable: + db.delete(r) + if total: + acc.coin_balance -= total + acc.total_coin_earned = max(0, acc.total_coin_earned - total) + print(f" 已退回 {total} 金币({len(refundable)}/{len(rows)} 笔)") + + stuck = len(rows) - len(refundable) + if stuck: + # 典型场景:签完就把金币兑换成现金了(exchange_out),这笔奖励已经变成 cash_balance_cents, + # 余额里已经没有它了。硬退会把余额退成负数 / 夹到 0 又会吃掉别处赚的金币,两者都会让账对不上。 + print(f" ⚠️ 还有 {stuck} 笔今天的签到流水退不掉(金币已被兑换/花掉,余额 {acc.coin_balance} 兜不住)," + f"原样保留 —— 硬退会让余额和流水总和对不上。") + print(" → 收益明细今天会多出几条签到记录,不影响签到功能测试;想彻底清干净用 reset_signin.py --with-coins。") + + +def main() -> None: + parser = argparse.ArgumentParser(description="撤销用户今天的签到,让今天能重新签") + parser.add_argument("phone", nargs="?", default=DEFAULT_PHONE, + help=f"手机号(默认 {DEFAULT_PHONE})") + parser.add_argument("--user-id", type=int, default=None, help="直接按 user_id 定位,优先于 phone") + parser.add_argument("--keep-coins", action="store_true", + help="不退已发金币(余额会越测越高,且留下重复流水)") + parser.add_argument("--dry-run", action="store_true", help="预览,最后回滚不落库") + args = parser.parse_args() + + if not args.dry_run and settings.APP_ENV != "dev": + raise SystemExit(f"APP_ENV={settings.APP_ENV},拒绝改库(只有 dev 能改;--dry-run 可任意环境)") + + today = cn_today() + db = SessionLocal() + try: + user = resolve_user(db, args.phone, args.user_id) + print(f"DB: {settings.DATABASE_URL}") + print(f"用户: id={user.id} phone={user.phone} 今天(北京): {today} keep_coins: {args.keep_coins}") + print_state(db, user, today, "before") + + # 今天的签到记录 —— 只删今天,昨天及以前保留,重签后 streak 接着涨 + rec = db.execute( + select(SigninRecord).where( + SigninRecord.user_id == user.id, SigninRecord.signin_date == today + ) + ).scalar_one_or_none() + if rec is not None: + db.delete(rec) + + if rec is None: + print("今天本来就没签到,无需处理。") + db.rollback() + return + + # 退金币 + if args.keep_coins: + print("(--keep-coins:保留已发金币,流水和余额不动)") + else: + refund_today(db, user.id, today) + # 注:更早流水的 balance_after 是当时的快照,不回改 —— 收益明细里历史行的 + # 余额列会与现余额对不上,dev 测试库无妨。 + + # SessionLocal 是 autoflush=False,不 flush 的话下面 print_state 的 select + # 读到的还是删之前的旧行,"after" 会骗人 + db.flush() + print_state(db, user, today, "after") + + if args.dry_run: + db.rollback() + print("(dry-run:以上 after 为预览,已回滚,库没动)") + return + db.commit() + print(f"完成:{user.phone} 今天({today})可以重新签到了。" + f"提醒:App 内存状态不会自动同步,杀掉重进福利页(当天未签到)会重新自动弹签到弹窗。") + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/seed_inactivity_cases.py b/scripts/seed_inactivity_cases.py index a1293d6..e8c9963 100644 --- a/scripts/seed_inactivity_cases.py +++ b/scripts/seed_inactivity_cases.py @@ -33,6 +33,7 @@ from app.models.wallet import ( # noqa: E402 CoinTransaction, InviteCashTransaction, ) +from app.repositories import activity # noqa: E402 from app.repositories import wallet as wallet_repo # noqa: E402 MARK = "vcase" # username 前缀,用于清理 @@ -47,7 +48,7 @@ CASES = [ ("6 只有现金", 30, 0, 200, 0, None, "清 cash;审计1行+1流水"), ("7 只有邀请(红线)", 30, 0, 0, 300, None, "不选中/不清/无审计/无流水;invite=300 原封"), ("8 预警窗(10天)", 10, 50, 60, 70, None, "不清;发 T-7 预警;notification_log 1行;余额不动"), - ("9 活跃兜底", 30, 100, 200, 300, 1, "昨日 home_view→last_active 近→不清不警"), + ("9 活跃兜底", 30, 100, 200, 300, 1, "昨日 home_visible→last_active 近→不清不警"), ("10 新用户(3天)", 3, 100, 200, 0, None, "created_at 近→不清不警"), ] @@ -86,8 +87,8 @@ def seed(db) -> None: acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents = coin, cash, invite acc.total_coin_earned = coin if ev_days is not None: - db.add(AnalyticsEvent( # 首页可见 = event=show + page=home - event="show", page="home", device_id=MARK, user_id=u.id, + db.add(AnalyticsEvent( # 首页可见 = event=home_visible(单一 event 名,见 activity.ACTIVE_EVENTS) + event=activity.HOME_VISIBLE_EVENT, device_id=MARK, user_id=u.id, client_ts=0, created_at=now - timedelta(days=ev_days), )) db.flush() diff --git a/tests/test_ad_ecpm_trace_revenue.py b/tests/test_ad_ecpm_trace_revenue.py index cd800e0..c3601c0 100644 --- a/tests/test_ad_ecpm_trace_revenue.py +++ b/tests/test_ad_ecpm_trace_revenue.py @@ -1,12 +1,13 @@ """ad_ecpm_record.trace_id 落库 + 按 trace 聚合广告收益(元)。""" from __future__ import annotations -from datetime import UTC, datetime +from datetime import UTC, date, datetime from sqlalchemy import delete from app.db.session import SessionLocal from app.models.ad_ecpm import AdEcpmRecord +from app.models.coupon_state import CouponSession from app.repositories import ad_ecpm as crud_ecpm @@ -57,6 +58,71 @@ def test_revenue_yuan_by_trace_empty() -> None: db.close() +def test_short_exposure_keeps_record_with_zero_revenue() -> None: + """不足一秒仍落展示记录,以便后台显示 0 而不是未填充。""" + db = SessionLocal() + try: + rec = crud_ecpm.create_ecpm_record( + db, 1, ad_type="draw", ecpm_raw="350", + ad_session_id="sess-short-exposure", feed_scene="coupon", + trace_id="trace-short-exposure", exposure_ms=999, + ) + assert rec.ecpm_raw == "0" + assert crud_ecpm.revenue_yuan_by_trace(db, ["trace-short-exposure"]) == { + "trace-short-exposure": 0.0 + } + finally: + db.execute(delete(AdEcpmRecord).where( + AdEcpmRecord.ad_session_id == "sess-short-exposure" + )) + db.commit() + db.close() + + +def test_one_second_exposure_keeps_original_ecpm() -> None: + assert crud_ecpm.effective_ecpm_raw("350", 1000) == "350" + assert crud_ecpm.effective_ecpm_raw("350", None) == "350" + + +def test_terminal_coupon_trace_is_not_attributable_to_late_impression() -> None: + """领券失败或被放弃后才到达的广告展示保留收益记录,但不再关联死亡 trace。""" + db = SessionLocal() + try: + db.add_all([ + CouponSession( + trace_id="failed-before-ad", device_id="d-late-ad", status="failed", app_env="prod", + started_at=datetime(2020, 1, 2, tzinfo=UTC), started_date=date(2020, 1, 2), + ), + CouponSession( + trace_id="abandoned-before-ad", device_id="d-late-ad", status="abandoned", app_env="prod", + started_at=datetime(2020, 1, 2, tzinfo=UTC), started_date=date(2020, 1, 2), + ), + ]) + db.flush() + + assert crud_ecpm.attributable_trace_id( + db, feed_scene="coupon", trace_id="failed-before-ad" + ) is None + assert crud_ecpm.attributable_trace_id( + db, feed_scene="coupon", trace_id="abandoned-before-ad" + ) is None + assert crud_ecpm.attributable_trace_id( + db, feed_scene="coupon", trace_id="failed-before-ad", exposure_ms=999 + ) == "failed-before-ad" + assert crud_ecpm.attributable_trace_id( + db, feed_scene="coupon", trace_id="abandoned-before-ad", exposure_ms=999 + ) == "abandoned-before-ad" + assert crud_ecpm.attributable_trace_id( + db, feed_scene="comparison", trace_id="failed-before-ad" + ) == "failed-before-ad" + assert crud_ecpm.attributable_trace_id( + db, feed_scene="coupon", trace_id="unknown-trace" + ) == "unknown-trace" + finally: + db.rollback() + db.close() + + def test_create_ecpm_record_persists_trace_id() -> None: """create_ecpm_record 落 trace_id。""" db = SessionLocal() diff --git a/tests/test_ad_reward.py b/tests/test_ad_reward.py index ebc0c98..1b1603b 100644 --- a/tests/test_ad_reward.py +++ b/tests/test_ad_reward.py @@ -356,3 +356,272 @@ def test_callback_disabled_returns_503(client, monkeypatch) -> None: # 503 发生在验签/发奖之前,不需要真实用户 r = _callback(client, _signed(1, "trans_disabled")) assert r.status_code == 503, r.text + + +# ===== 按 ad_session_id 查权威发奖结果(GET /reward-result/{ad_session_id})===== +# 客户端看完广告轮询它拿弹窗金额:只认 status='granted' 且 coin>0,其余一律不弹。 + + +def _reward_result(client, token: str, session_id: str): + return client.get(f"/api/v1/ad/reward-result/{session_id}", headers=_auth(token)) + + +def _session_extra(session_id: str, **kv: str) -> str: + return json.dumps({"ad_session_id": session_id, **kv}) + + +def test_reward_result_pending_when_s2s_not_arrived(client) -> None: + """S2S 还没回调 → 200 + pending(**不是 404**),客户端据此继续轮询。""" + token = _login(client, "13800003601") + + r = _reward_result(client, token, "sess-not-yet-arrived") + assert r.status_code == 200, r.text + assert r.json() == { + "ad_session_id": "sess-not-yet-arrived", + "status": "pending", + "coin": None, + # 没记录 → 连属于哪一轮都不知道,累计值一并为 null(不是 0,0 会被读成"本轮没赚到") + "round_coin": None, + } + + +def test_reward_result_returns_granted_coin(client) -> None: + """S2S 发奖后按会话查 → granted + 本次真实到账额(与钱包入账一致)。""" + phone = "13800003602" + token = _login(client, phone) + uid = _user_id(phone) + session_id = "sess-granted-1" + + r = _callback( + client, + _signed(uid, "trans_rr_1", ecpm="200", extra=_session_extra(session_id)), + ) + assert r.json() == {"is_verify": True, "reason": 0} + + expected = calculate_ad_reward_coin("200", 1) + body = _reward_result(client, token, session_id).json() + assert body["status"] == "granted" + assert body["coin"] == expected + # 弹窗金额必须等于真实入账,这正是本接口存在的意义(不用余额差估算) + assert _coin_balance(client, token) == expected + + +def test_reward_result_prefers_granted_over_earlier_noshow(client) -> None: + """竞态:客户端先报 closed_early、S2S 随后才到 → 同一会话两条记录,必须返回 granted 那条。 + + 只按 created_at 取最近一条是不够的(SQLite 下两条可能同一时间戳),故仓储层显式优先 granted。 + """ + phone = "13800003603" + token = _login(client, phone) + uid = _user_id(phone) + session_id = "sess-race-noshow" + + # 1) 客户端以为没发奖,先留痕 + r = client.post( + "/api/v1/ad/reward-noshow", + json={"ad_session_id": session_id, "watched_seconds": 3}, + headers=_auth(token), + ) + assert r.status_code == 200, r.text + assert r.json()["status"] == "closed_early" + assert _reward_result(client, token, session_id).json()["status"] == "closed_early" + + # 2) S2S 姗姗来迟,真发了钱 + _callback(client, _signed(uid, "trans_rr_race", ecpm="200", extra=_session_extra(session_id))) + + body = _reward_result(client, token, session_id).json() + assert body["status"] == "granted" + assert body["coin"] == calculate_ad_reward_coin("200", 1) + + +def test_reward_result_capped_reports_zero_not_popup(client) -> None: + """达每日上限 → capped + coin=0;客户端不弹「获得 0 金币」。""" + phone = "13800003604" + token = _login(client, phone) + uid = _user_id(phone) + session_id = "sess-capped-1" + + db = SessionLocal() + try: + db.add( + AdRewardRecord( + trans_id="trans_rr_capped", user_id=_user_id(phone), coin=0, status="capped", + reward_scene="reward_video", ad_session_id=session_id, reward_date="2026-07-17", + ) + ) + db.commit() + finally: + db.close() + assert uid # 记录挂在该用户名下 + + body = _reward_result(client, token, session_id).json() + assert body["status"] == "capped" + assert body["coin"] == 0 + + +def test_reward_result_scoped_to_owner(client) -> None: + """别人的会话查不到(按 user_id 收窄)→ pending,不泄漏他人发奖结果。""" + phone_a = "13800003605" + token_a = _login(client, phone_a) + uid_a = _user_id(phone_a) + token_b = _login(client, "13800003606") + session_id = "sess-owner-only" + + _callback(client, _signed(uid_a, "trans_rr_owner", ecpm="200", extra=_session_extra(session_id))) + + assert _reward_result(client, token_a, session_id).json()["status"] == "granted" + assert _reward_result(client, token_b, session_id).json()["status"] == "pending" + + +def test_reward_result_requires_auth(client) -> None: + """无 Bearer → 401,不裸奔。""" + assert client.get("/api/v1/ad/reward-result/sess-anon-1").status_code == 401 + + +# ===== 膨胀轮累计(boost_round_id → reward-result.round_coin)===== +# 不变量:弹窗数字 == 本轮实际到账之和 == 余额涨幅。三者对不上用户就认为少发了钱。 + + +def _round_extra(session_id: str, round_id: str | None = None, **kv: str) -> str: + data = {"ad_session_id": session_id, **kv} + if round_id is not None: + data["boost_round_id"] = round_id + return json.dumps(data) + + +def test_round_coin_accumulates_across_ads_in_same_round(client) -> None: + """一轮连看两条 → round_coin 逐条累计,且等于余额涨幅(第七节验收 1、2 步)。""" + phone = "13800003701" + token = _login(client, phone) + uid = _user_id(phone) + round_id = "b7e1c93a4f6d802b" + + _callback(client, _signed( + uid, "trans_round_1", ecpm="200", extra=_round_extra("sess-r1-a", round_id))) + first = calculate_ad_reward_coin("200", 1) + body = _reward_result(client, token, "sess-r1-a").json() + assert body["coin"] == first + assert body["round_coin"] == first # 第 1 条:本轮累计 == 本条 + + _callback(client, _signed( + uid, "trans_round_2", ecpm="200", extra=_round_extra("sess-r1-b", round_id))) + second = calculate_ad_reward_coin("200", 2) # LT 因子递减,第 2 条比第 1 条少 + body = _reward_result(client, token, "sess-r1-b").json() + assert body["coin"] == second + assert body["round_coin"] == first + second # 累计 = 两条之和 + + # 弹窗数字必须等于真实余额涨幅 —— 这条不变量是整个方案的目的 + assert _coin_balance(client, token) == first + second + + +def test_new_round_restarts_accumulation(client) -> None: + """换新轮 id → round_coin 从头累计,不接着上一轮往上加(第七节最后一句验收)。""" + phone = "13800003702" + token = _login(client, phone) + uid = _user_id(phone) + + _callback(client, _signed(uid, "trans_r2_old", ecpm="200", extra=_round_extra("sess-r2-a", "round-old"))) + old = _reward_result(client, token, "sess-r2-a").json()["round_coin"] + assert old > 0 + + _callback(client, _signed(uid, "trans_r2_new", ecpm="200", extra=_round_extra("sess-r2-b", "round-new"))) + body = _reward_result(client, token, "sess-r2-b").json() + assert body["round_coin"] == body["coin"] # 新轮 = 只有本条 + assert body["round_coin"] != old + body["coin"] + + +def test_round_coin_null_without_round_id(client) -> None: + """extra 没带 boost_round_id(老客户端 / GroMore 丢字段)→ round_coin=null,客户端退回显示单条。""" + phone = "13800003703" + token = _login(client, phone) + uid = _user_id(phone) + + _callback(client, _signed(uid, "trans_r3", ecpm="200", extra=_round_extra("sess-r3-noround", None))) + body = _reward_result(client, token, "sess-r3-noround").json() + assert body["coin"] == calculate_ad_reward_coin("200", 1) + assert body["round_coin"] is None + + +def test_round_coin_null_when_pending(client) -> None: + """S2S 未到账 → 没有记录 → 连轮 id 都不知道,round_coin 也是 null(不是 0)。""" + token = _login(client, "13800003704") + body = _reward_result(client, token, "sess-r4-pending").json() + assert body == { + "ad_session_id": "sess-r4-pending", + "status": "pending", + "coin": None, + "round_coin": None, + } + + +def test_round_coin_returned_on_capped(client) -> None: + """撞每日上限那条不是 granted,但 round_coin **仍返本轮累计**(该条按 0 计)。 + + 客户端的限额 toast 要显示前面几条已到账的总额,不能是空。 + """ + phone = "13800003705" + token = _login(client, phone) + uid = _user_id(phone) + round_id = "round-capped" + + _callback(client, _signed(uid, "trans_cap_ok", ecpm="200", extra=_round_extra("sess-cap-a", round_id))) + earned = _reward_result(client, token, "sess-cap-a").json()["round_coin"] + assert earned > 0 + + # 手插一条同轮的 capped 记录(跑满 500 次太慢),模拟第 N 条撞上限 + db = SessionLocal() + try: + db.add(AdRewardRecord( + trans_id="trans_cap_hit", user_id=uid, coin=0, status="capped", + reward_scene="reward_video", ad_session_id="sess-cap-b", + reward_date="2026-07-20", boost_round_id=round_id, + )) + db.commit() + finally: + db.close() + + body = _reward_result(client, token, "sess-cap-b").json() + assert body["status"] == "capped" + assert body["coin"] == 0 # 这条没发钱 + assert body["round_coin"] == earned # 但本轮累计照常返回 + + +def test_round_coin_scoped_to_owner(client) -> None: + """轮 id 是客户端生成的,不能跨用户信任:拿别人的轮 id 查不到别人的金币。""" + phone_a = "13800003706" + token_a = _login(client, phone_a) + uid_a = _user_id(phone_a) + phone_b = "13800003707" + token_b = _login(client, phone_b) + uid_b = _user_id(phone_b) + shared_round = "round-collision" + + _callback(client, _signed(uid_a, "trans_own_a", ecpm="200", extra=_round_extra("sess-own-a", shared_round))) + a_total = _reward_result(client, token_a, "sess-own-a").json()["round_coin"] + + # B 用同一个轮 id(伪造或碰撞)看一条:B 的累计里不能混进 A 的钱 + _callback(client, _signed(uid_b, "trans_own_b", ecpm="200", extra=_round_extra("sess-own-b", shared_round))) + b_body = _reward_result(client, token_b, "sess-own-b").json() + assert b_body["round_coin"] == b_body["coin"] + assert b_body["round_coin"] < a_total + b_body["coin"] + + +def test_test_grant_accepts_boost_round_id(client, monkeypatch) -> None: + """debug 的 test-grant 不经 S2S、拿不到 mediaExtra,轮 id 由 body 补 → 本地也能验累计。""" + monkeypatch.setattr(settings, "AD_REWARD_TEST_GRANT_ENABLED", True) + token = _login(client, "13800003708") + round_id = "round-testgrant" + + coins = [] + for i in range(2): + r = client.post( + "/api/v1/ad/test-grant", + json={"reward_scene": "reward_video", "boost_round_id": round_id, + "ad_session_id": f"sess-tg-{i}-padding"}, + headers=_auth(token), + ) + assert r.status_code == 200, r.text + coins.append(r.json()["coin"]) + + body = _reward_result(client, token, "sess-tg-1-padding").json() + assert body["round_coin"] == sum(coins) diff --git a/tests/test_admin_ad_revenue_scope.py b/tests/test_admin_ad_revenue_scope.py new file mode 100644 index 0000000..b01c92a --- /dev/null +++ b/tests/test_admin_ad_revenue_scope.py @@ -0,0 +1,129 @@ +"""广告收益报表的环境与业务代码位口径。""" +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import delete + +from app.admin.repositories import ad_revenue +from app.db.session import SessionLocal +from app.models.ad_ecpm import AdEcpmRecord +from app.models.ad_pangle_revenue import AdPangleDailyRevenue +from app.models.user import User + +REPORT_DATE = "2040-02-03" + + +def test_business_scope_filters_client_and_pangle_by_env_and_code(monkeypatch) -> None: + db = SessionLocal() + try: + user = User( + phone="18800009991", + username="29999999991", + register_channel="sms", + ) + db.add(user) + db.flush() + db.add_all([ + AdEcpmRecord( + user_id=user.id, ad_type="reward_video", ad_session_id="scope-prod-business", + app_env="prod", our_code_id="prod-reward", ecpm_raw="10000", + report_date=REPORT_DATE, created_at=datetime(2040, 2, 3, tzinfo=UTC), + ), + AdEcpmRecord( + user_id=user.id, ad_type="draw", ad_session_id="scope-prod-demo", + app_env="prod", our_code_id="prod-demo", ecpm_raw="20000", + report_date=REPORT_DATE, created_at=datetime(2040, 2, 3, tzinfo=UTC), + ), + AdEcpmRecord( + user_id=user.id, ad_type="draw", ad_session_id="scope-prod-known-business", + app_env="prod", our_code_id="104098712", ecpm_raw="40000", + report_date=REPORT_DATE, created_at=datetime(2040, 2, 3, tzinfo=UTC), + ), + AdEcpmRecord( + user_id=user.id, ad_type="reward_video", ad_session_id="scope-test-business", + app_env="test", our_code_id="104127529", ecpm_raw="30000", + report_date=REPORT_DATE, created_at=datetime(2040, 2, 3, tzinfo=UTC), + ), + AdPangleDailyRevenue( + report_date=REPORT_DATE, app_env="prod", our_code_id="prod-reward", + adn="", revenue_yuan=1.5, api_revenue_yuan=1.2, impressions=10, + ), + AdPangleDailyRevenue( + report_date=REPORT_DATE, app_env="prod", our_code_id="prod-demo", + adn="", revenue_yuan=8.0, api_revenue_yuan=7.0, impressions=40, + ), + AdPangleDailyRevenue( + report_date=REPORT_DATE, app_env="prod", our_code_id="104098712", + adn="", revenue_yuan=2.5, api_revenue_yuan=2.0, impressions=20, + ), + AdPangleDailyRevenue( + report_date=REPORT_DATE, app_env="test", our_code_id="104127529", + adn="", revenue_yuan=9.0, api_revenue_yuan=8.0, impressions=50, + ), + ]) + db.commit() + monkeypatch.setattr( + ad_revenue.app_config, + "get_ad_config", + lambda _db: { + "reward_code_id": "prod-reward", + "compare_draw_code_id": "prod-draw", + "coupon_draw_code_id": "prod-draw", + }, + ) + + business = ad_revenue.ad_revenue_report( + db, + date_from=REPORT_DATE, + date_to=REPORT_DATE, + app_env="prod", + revenue_scope="business", + ) + assert business["total_impressions"] == 2 + assert business["total_revenue_yuan"] == 0.5 + assert business["total_pangle_revenue_yuan"] == 4.0 + assert business["total_pangle_api_revenue_yuan"] == 3.2 + + all_codes = ad_revenue.ad_revenue_report( + db, + date_from=REPORT_DATE, + date_to=REPORT_DATE, + app_env="prod", + revenue_scope="all", + ) + assert all_codes["total_impressions"] == 3 + assert all_codes["total_revenue_yuan"] == 0.7 + assert all_codes["total_pangle_revenue_yuan"] == 12.0 + assert all_codes["total_pangle_api_revenue_yuan"] == 10.2 + + test_business = ad_revenue.ad_revenue_report( + db, + date_from=REPORT_DATE, + date_to=REPORT_DATE, + app_env="test", + revenue_scope="business", + ) + assert test_business["total_impressions"] == 1 + assert test_business["total_revenue_yuan"] == 0.3 + assert test_business["total_pangle_revenue_yuan"] == 9.0 + assert test_business["total_pangle_api_revenue_yuan"] == 8.0 + + all_env_business = ad_revenue.ad_revenue_report( + db, + date_from=REPORT_DATE, + date_to=REPORT_DATE, + app_env=None, + revenue_scope="business", + ) + assert all_env_business["total_impressions"] == 3 + assert all_env_business["total_revenue_yuan"] == 0.8 + assert all_env_business["total_pangle_revenue_yuan"] == 13.0 + assert all_env_business["total_pangle_api_revenue_yuan"] == 11.2 + finally: + db.rollback() + db.execute(delete(AdPangleDailyRevenue).where(AdPangleDailyRevenue.report_date == REPORT_DATE)) + db.execute(delete(AdEcpmRecord).where(AdEcpmRecord.report_date == REPORT_DATE)) + db.execute(delete(User).where(User.phone == "18800009991")) + db.commit() + db.close() diff --git a/tests/test_admin_read.py b/tests/test_admin_read.py index 5f5313f..d2b6526 100644 --- a/tests/test_admin_read.py +++ b/tests/test_admin_read.py @@ -1,12 +1,15 @@ """Admin M2 读接口测试:大盘聚合 + 用户/流水/提现/反馈列表 + 鉴权拦截。""" from __future__ import annotations +from datetime import datetime + import pytest from fastapi.testclient import TestClient from app.admin.main import admin_app from app.admin.repositories import admin_user as admin_repo from app.db.session import SessionLocal +from app.models.comparison import ComparisonRecord from app.models.feedback import Feedback from app.models.wallet import CashTransaction, WithdrawOrder from app.repositories import user as user_repo @@ -69,6 +72,49 @@ def test_dashboard_overview(admin_client: TestClient, admin_token: str) -> None: assert "jd_order_count" in data["cps"] +def test_dashboard_period_comparison_is_aggregated_by_backend( + admin_client: TestClient, admin_token: str +) -> None: + created_at = datetime(2037, 1, 15, 12) + rows = [ + ("dashboard-aggregate-success", "success", 101, 0.1), + ("dashboard-aggregate-failed", "failed", 200, 0.2), + ("dashboard-aggregate-cancelled", "cancelled", 300, 0.3), + ("dashboard-aggregate-running", "running", 400, 0.4), + ] + db = SessionLocal() + try: + for trace_id, status, total_ms, llm_cost_yuan in rows: + db.add( + ComparisonRecord( + trace_id=trace_id, + status=status, + total_ms=total_ms, + llm_cost_yuan=llm_cost_yuan, + created_at=created_at, + ) + ) + db.commit() + finally: + db.close() + + response = admin_client.get( + "/admin/api/stats/overview", + params={"date_from": "2037-01-15", "date_to": "2037-01-15"}, + headers=_auth(admin_token), + ) + assert response.status_code == 200, response.text + comparison = response.json()["period"]["comparison"] + assert comparison["total"] == 4 + assert comparison["completed"] == 2 + assert comparison["cancelled"] == 1 + assert comparison["success"] == 1 + assert comparison["success_rate"] == 0.3333 + assert comparison["median_duration_ms"] == 151 + assert comparison["p95_duration_ms"] == 195 + assert comparison["token_cost_total_yuan"] == pytest.approx(1.0) + + def test_user_list_and_detail(admin_client: TestClient, admin_token: str) -> None: uid = _seed_user_with_data("13800000002") r = admin_client.get("/admin/api/users", headers=_auth(admin_token)) @@ -409,3 +455,62 @@ def test_period_coupon_reward_excludes_reward_video( coins = r.json()["period"]["coins"] assert coins["coupon_reward_coin_total"] == 0 # 激励视频不计入领券奖励 assert coins["reward_video_coin_total"] == 50 # 仍计入激励视频卡 + + +def test_period_regular_task_coin_uses_explicit_allowlist( + admin_client: TestClient, admin_token: str +) -> None: + """常规任务金币只加明确任务来源,领券/比价及未知新类型不得自动混入。""" + from datetime import datetime + + from app.models.wallet import CoinTransaction + + d = "2021-06-18" + included = { + "signin": 100, + "signin_boost": 200, + "task_enable_notification": 300, + "task_other": 400, + "price_report_reward": 500, + "feedback_reward": 600, + } + excluded = { + "feed_ad_reward_coupon": 700, + "feed_ad_reward_comparison": 800, + "feed_ad_reward": 900, + "reward_video": 1000, + "admin_grant": 1100, + "invite_inviter": 1200, + "future_unknown_reward": 1300, + } + db = SessionLocal() + try: + uid = user_repo.upsert_user_for_login( + db, phone="13800008804", register_channel="sms" + ).id + balance = 0 + rows = [] + for index, (biz_type, amount) in enumerate((included | excluded).items(), start=1): + balance += amount + rows.append(CoinTransaction( + user_id=uid, + amount=amount, + balance_after=balance, + biz_type=biz_type, + ref_id=f"regular-task-{index}", + created_at=datetime(2021, 6, 18, 12, 0, index), + )) + db.add_all(rows) + db.commit() + finally: + db.close() + + response = admin_client.get( + "/admin/api/stats/overview", + params={"date_from": d, "date_to": d}, + headers=_auth(admin_token), + ) + assert response.status_code == 200, response.text + coins = response.json()["period"]["coins"] + assert coins["regular_task_coin_total"] == sum(included.values()) + assert coins["task_coin_total"] == 700 diff --git a/tests/test_admin_roles.py b/tests/test_admin_roles.py index 85ab010..fc1603f 100644 --- a/tests/test_admin_roles.py +++ b/tests/test_admin_roles.py @@ -123,7 +123,8 @@ def test_builtin_roles_labels_and_pages(admin_client, super_token) -> None: # 页集对齐 Prototypes/dashboard/permissions.md 的 ROLES assert set(roles["finance"]["pages"]) == {"dashboard", "ad-revenue-report", "cps", "withdraws"} assert set(roles["tech"]["pages"]) == { - "dashboard", "device-liveness", "config", "ad-revenue", "event-logs", "audit-logs", + "dashboard", "device-liveness", "config", "ad-revenue", "huawei-review", + "event-logs", "audit-logs", } diff --git a/tests/test_comparison_admin_summary.py b/tests/test_comparison_admin_summary.py new file mode 100644 index 0000000..5006fd6 --- /dev/null +++ b/tests/test_comparison_admin_summary.py @@ -0,0 +1,65 @@ +"""比价记录页后端分页与概览聚合。""" +from __future__ import annotations + +from datetime import UTC, date, datetime + +import pytest + +from app.admin.repositories import queries +from app.db.session import SessionLocal +from app.models.comparison import ComparisonRecord + + +def test_summary_uses_only_success_durations_and_filters_beijing_date() -> None: + db = SessionLocal() + try: + rows = [ + ("summary-success-a", "success", 1000, 1.0, 100), + ("summary-success-b", "success", 3000, 2.0, 0), + ("summary-failed", "failed", 100_000, 3.0, 0), + ("summary-cancelled", "cancelled", 5000, 4.0, 0), + ] + for trace_id, status, total_ms, cost, saved in rows: + db.add(ComparisonRecord( + trace_id=trace_id, + status=status, + total_ms=total_ms, + llm_cost_yuan=cost, + saved_amount_cents=saved, + created_at=datetime(2038, 1, 15, 12, tzinfo=UTC), + )) + db.add(ComparisonRecord( + trace_id="summary-outside-day", + status="success", + total_ms=999_999, + created_at=datetime(2038, 1, 16, 16, tzinfo=UTC), + )) + db.flush() + + summary = queries.comparison_records_summary( + db, date_from=date(2038, 1, 15), date_to=date(2038, 1, 15) + ) + + assert summary["started"] == 4 + assert summary["completed"] == 3 + assert summary["success"] == 2 + assert summary["success_rate"] == pytest.approx(2 / 3) + assert summary["avg_token_cost"] == pytest.approx(2.5) + assert summary["lower_price_rate"] == 0.5 + assert summary["avg_duration_ms"] == 2000 + assert summary["p5_duration_ms"] == 1100 + assert summary["p50_duration_ms"] == 2000 + assert summary["p95_duration_ms"] == 2900 + assert summary["p99_duration_ms"] == 2980 + assert summary["cancelled"] == 1 + assert summary["cancelled_rate"] == 0.25 + assert summary["cancelled_p50_ms"] == 5000 + + items, _next_cursor, total = queries.list_comparison_records( + db, date_from=date(2038, 1, 15), date_to=date(2038, 1, 15), limit=20 + ) + assert total == 4 + assert {item.trace_id for item in items} == {row[0] for row in rows} + finally: + db.rollback() + db.close() diff --git a/tests/test_coupon_point_score.py b/tests/test_coupon_point_score.py new file mode 100644 index 0000000..5108fcf --- /dev/null +++ b/tests/test_coupon_point_score.py @@ -0,0 +1,161 @@ +"""admin 领券明细逐场点位分数与按需明细。""" +from datetime import UTC, date, datetime + +from fastapi.testclient import TestClient +from sqlalchemy import delete + +from app.admin.main import admin_app +from app.admin.repositories import admin_user as admin_repo +from app.admin.repositories.coupon_data import ( + _point_scores_by_trace, + coupon_data_report, + coupon_point_details, +) +from app.admin.security import create_admin_token +from app.db.session import SessionLocal +from app.models.coupon_state import CouponClaimRecord, CouponSession + + +def test_point_scores_by_trace() -> None: + """已领算成功、失败算尝试、跳过不进分母。""" + db = SessionLocal() + trace = "point-score-trace" + try: + db.add_all([ + CouponClaimRecord( + device_id="score-device", + coupon_id=f"mt-score-{status}", + claim_date=date(2020, 1, 2), + status=status, + coupon_name=f"测试点位-{status}", + reason="测试失败" if status == "failed" else None, + trace_id=trace, + ) + for status in ("success", "already_claimed", "failed", "skipped") + ]) + db.flush() + + stats = _point_scores_by_trace(db, [trace])[trace] + assert stats["succeeded"] == 2 + assert stats["tried"] == 3 + details = coupon_point_details(db, trace_id=trace) + assert [item["status"] for item in details] == [ + "success", "already_claimed", "failed", "skipped" + ] + assert details[2]["reason"] == "测试失败" + finally: + db.rollback() + db.close() + + +def test_skipped_detail_does_not_create_a_score() -> None: + """仅有 skipped 时按需明细仍可查到,但列表没有虚假的 0/0 分数。""" + db = SessionLocal() + trace = "point-score-skipped" + try: + db.add(CouponClaimRecord( + device_id="score-device-skipped", + coupon_id="mt-score-skipped-only", + claim_date=date(2020, 1, 2), + status="skipped", + trace_id=trace, + )) + db.flush() + + scores = _point_scores_by_trace(db, [trace, "missing-trace"]) + assert trace not in scores + assert "missing-trace" not in scores + assert coupon_point_details(db, trace_id=trace)[0]["status"] == "skipped" + finally: + db.rollback() + db.close() + + +def test_coupon_data_report_returns_scores_without_embedding_details() -> None: + """主列表只返回聚合分数,逐券记录必须走按 trace 的明细查询。""" + db = SessionLocal() + trace = "point-score-report" + report_date = date(2020, 1, 4) + try: + db.add(CouponSession( + trace_id=trace, + device_id="score-report-device", + status="completed", + app_env="prod", + platforms=["meituan-waimai"], + started_at=datetime(2020, 1, 4, tzinfo=UTC), + started_date=report_date, + )) + db.add_all([ + CouponClaimRecord( + device_id="score-report-device", + coupon_id=f"mt-report-{status}", + claim_date=report_date, + status=status, + trace_id=trace, + ) + for status in ("success", "failed") + ]) + db.flush() + + report = coupon_data_report( + db, + date_from=report_date.isoformat(), + date_to=report_date.isoformat(), + app_env="prod", + ) + row = next(item for item in report["items"] if item["trace_id"] == trace) + assert row["point_success_count"] == 1 + assert row["point_total_count"] == 2 + assert "point_details" not in row + assert len(coupon_point_details(db, trace_id=trace)) == 2 + finally: + db.rollback() + db.close() + + +def test_coupon_point_details_endpoint() -> None: + """前端点击使用的接口按约定返回 trace_id 和逐券 items。""" + db = SessionLocal() + trace = "point-details-endpoint" + try: + admin = admin_repo.get_by_username(db, "point_details_admin") + if admin is None: + admin = admin_repo.create_admin( + db, + username="point_details_admin", + password="pass1234", + role="super_admin", + ) + token, _expires_at = create_admin_token(admin_id=admin.id, role=admin.role) + db.add(CouponClaimRecord( + device_id="point-details-endpoint-device", + coupon_id="mt-point-details-endpoint", + coupon_name="接口测试券", + claim_date=date(2020, 1, 5), + status="failed", + reason="接口测试失败", + trace_id=trace, + )) + db.commit() + + response = TestClient(admin_app).get( + "/admin/api/coupon-data/point-details", + params={"trace_id": trace}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 200, response.text + assert response.json() == { + "trace_id": trace, + "items": [{ + "coupon_id": "mt-point-details-endpoint", + "coupon_name": "接口测试券", + "status": "failed", + "reason": "接口测试失败", + }], + } + finally: + db.rollback() + db.execute(delete(CouponClaimRecord).where(CouponClaimRecord.trace_id == trace)) + db.commit() + db.close() diff --git a/tests/test_huawei_review.py b/tests/test_huawei_review.py new file mode 100644 index 0000000..76c97b2 --- /dev/null +++ b/tests/test_huawei_review.py @@ -0,0 +1,130 @@ +"""华为审核开关:admin 读写 + 客户端公开端点 + 审计 + 空库回退。 + +背景:华为应用市场审核要求新手引导的「快速设置」权限步必须可被用户关闭。开关切到 review 后 +客户端(仅华为 ROM)在该步左上角显示退出按钮。默认 default = 上线至今的现状(不可关闭)。 + +autouse 清理每个用例后清空 app_config,避免污染其他文件里假设默认值的用例(同 test_admin_config)。 +""" +from __future__ import annotations + +from collections.abc import Iterator + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import delete, select + +from app.admin.main import admin_app +from app.admin.repositories import admin_user as admin_repo +from app.db.session import SessionLocal +from app.models.admin import AdminAuditLog +from app.models.app_config import AppConfig + + +@pytest.fixture() +def admin_client() -> TestClient: + return TestClient(admin_app) + + +@pytest.fixture() +def token() -> str: + db = SessionLocal() + try: + if admin_repo.get_by_username(db, "hw_admin") is None: + admin_repo.create_admin( + db, username="hw_admin", password="hwpass123", role="super_admin" + ) + finally: + db.close() + c = TestClient(admin_app) + return c.post( + "/admin/api/auth/login", json={"username": "hw_admin", "password": "hwpass123"} + ).json()["access_token"] + + +@pytest.fixture(autouse=True) +def _clean_config() -> Iterator[None]: + yield + db = SessionLocal() + try: + db.execute(delete(AppConfig)) + # 审计行同样要清:同库跨用例累积会让 test_switch_writes_audit 数到前面用例写的行 + # (按 action 限定,不碰其他模块可能已写入的审计)。 + db.execute(delete(AdminAuditLog).where(AdminAuditLog.action == "huawei_review.set")) + db.commit() + finally: + db.close() + + +def _auth(t: str) -> dict: + return {"Authorization": f"Bearer {t}"} + + +def test_public_default_not_closable(client: TestClient) -> None: + """空库(从未切过)→ 客户端拿到 default / 不可关闭 = 维持现状;且不需要鉴权。""" + r = client.get("/api/v1/platform/huawei-review") + assert r.status_code == 200, r.text + assert r.json() == {"mode": "default", "onboarding_closable": False} + + +def test_admin_get_default(admin_client: TestClient, token: str) -> None: + r = admin_client.get("/admin/api/huawei-review", headers=_auth(token)) + assert r.status_code == 200, r.text + body = r.json() + assert body["mode"] == "default" + assert body["updated_at"] is None # 从未切过 + + +def test_switch_to_review_takes_effect( + admin_client: TestClient, client: TestClient, token: str +) -> None: + """admin 切 review → 公开端点立刻下发可关闭(跨进程/跨 app 一致,因为落在 DB 而非内存)。""" + r = admin_client.patch( + "/admin/api/huawei-review", json={"mode": "review"}, headers=_auth(token) + ) + assert r.status_code == 200, r.text + assert r.json()["mode"] == "review" + assert r.json()["updated_at"] is not None + + pub = client.get("/api/v1/platform/huawei-review").json() + assert pub == {"mode": "review", "onboarding_closable": True} + + # 切回 default → 客户端恢复不可关闭(审核过了要能一键收回) + admin_client.patch( + "/admin/api/huawei-review", json={"mode": "default"}, headers=_auth(token) + ) + assert client.get("/api/v1/platform/huawei-review").json()["onboarding_closable"] is False + + +def test_switch_writes_audit(admin_client: TestClient, token: str) -> None: + admin_client.patch( + "/admin/api/huawei-review", json={"mode": "review"}, headers=_auth(token) + ) + db = SessionLocal() + try: + logs = db.execute( + select(AdminAuditLog).where(AdminAuditLog.action == "huawei_review.set") + ).scalars().all() + assert len(logs) == 1 + assert logs[0].detail == {"before": "default", "after": "review"} + finally: + db.close() + + +def test_invalid_mode_rejected(admin_client: TestClient, token: str) -> None: + """mode 是 Literal,非法值由 FastAPI 校验挡在 422(不会落库)。""" + r = admin_client.patch( + "/admin/api/huawei-review", json={"mode": "nope"}, headers=_auth(token) + ) + assert r.status_code == 422, r.text + db = SessionLocal() + try: + assert db.get(AppConfig, "huawei_review") is None + finally: + db.close() + + +def test_requires_admin_auth(admin_client: TestClient) -> None: + assert admin_client.get("/admin/api/huawei-review").status_code == 401 + assert admin_client.patch( + "/admin/api/huawei-review", json={"mode": "review"} + ).status_code == 401 diff --git a/tests/test_inactivity_reset.py b/tests/test_inactivity_reset.py index 33fb217..e19d2b5 100644 --- a/tests/test_inactivity_reset.py +++ b/tests/test_inactivity_reset.py @@ -42,9 +42,9 @@ def test_reset_cutoff_is_cn_midnight_of_today_minus_days_minus_1() -> None: def test_active_event_constants() -> None: - # 首页可见 = event=show + page=home 组合,不在纯 event 名集合里 - assert activity.HOME_VIEW_EVENT == "show" and activity.HOME_VIEW_PAGE == "home" - assert activity.HOME_VIEW_EVENT not in activity.ACTIVE_EVENTS + # 首页可见:前端埋点确认 event=home_visible,单一 event 名,在 ACTIVE_EVENTS 中 + assert activity.HOME_VISIBLE_EVENT == "home_visible" + assert activity.HOME_VISIBLE_EVENT in activity.ACTIVE_EVENTS assert "real_compare_start" in activity.ACTIVE_EVENTS assert "real_coupon_start" in activity.ACTIVE_EVENTS assert activity.ACTIVE_ENGAGE_TYPE == "claim_started" @@ -133,15 +133,15 @@ def test_last_active_expr_takes_max_of_baseline_and_events() -> None: db.close() -def test_home_signal_uses_show_event_on_home_page() -> None: - """首页可见活跃口径 = event=show + page=home 组合;show 但非 home 页不算活跃。""" +def test_home_signal_uses_home_visible_event() -> None: + """首页可见活跃口径 = event=home_visible(单一事件名,前端埋点已确认);其他事件不算活跃。""" from sqlalchemy import select db = SessionLocal() try: base = datetime(2026, 1, 1, tzinfo=timezone.utc) - seen = _new_user(db, created_at=base) # show/home → 活跃 - _add_event(db, seen, "show", datetime(2026, 1, 10, tzinfo=timezone.utc), page="home") - other = _new_user(db, created_at=base) # show/其他页 → 不算活跃 + seen = _new_user(db, created_at=base) # home_visible → 活跃 + _add_event(db, seen, "home_visible", datetime(2026, 1, 10, tzinfo=timezone.utc)) + other = _new_user(db, created_at=base) # 其他事件 → 不算活跃 _add_event(db, other, "show", datetime(2026, 1, 10, tzinfo=timezone.utc), page="coupon") db.commit() @@ -156,8 +156,8 @@ def test_home_signal_uses_show_event_on_home_page() -> None: .where(User.id == uid)) return activity.norm_utc(db.execute(stmt).scalar_one()) - assert last_active(seen) == datetime(2026, 1, 10, tzinfo=timezone.utc) # show/home 算 - assert last_active(other) == base # show/其他页 不算 + assert last_active(seen) == datetime(2026, 1, 10, tzinfo=timezone.utc) # home_visible 算 + assert last_active(other) == base # 其他事件不算 finally: db.rollback() db.close() @@ -194,9 +194,9 @@ def test_run_reset_clears_coin_and_cash_but_preserves_invite_cash() -> None: # 末次活跃 = created_at 基线 = 1/10(距 today 22 天 → 应清) old = _new_user(db, created_at=datetime(2026, 1, 10, tzinfo=timezone.utc), coin=100, cash=200, invite=300) - # 活跃用户:昨天有 home_view → 不清 + # 活跃用户:昨天有 home_visible → 不清 fresh = _new_user(db, created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), coin=50) - _add_event(db, fresh, "show", datetime(2026, 1, 31, tzinfo=timezone.utc), page="home") + _add_event(db, fresh, "home_visible", datetime(2026, 1, 31, tzinfo=timezone.utc)) db.commit() stats = inactivity.run_reset_once(db, reset_days=15, today=today) diff --git a/tests/test_welfare.py b/tests/test_welfare.py index 07af7bc..bd99334 100644 --- a/tests/test_welfare.py +++ b/tests/test_welfare.py @@ -11,7 +11,6 @@ from app.core.rewards import ( COIN_PER_CENT, COIN_PER_YUAN, MIN_EXCHANGE_COIN, - SIGNIN_BOOST_COIN, SIGNIN_REWARDS, TASK_ENABLE_NOTIFICATION, TASK_REWARDS, @@ -106,48 +105,6 @@ def test_signin_flow(client) -> None: assert txn["balance_after"] == SIGNIN_REWARDS[0] -def test_signin_boost_flow(client) -> None: - """签到后看广告膨胀 → S2S 固定补发 2000 金币,每天只能膨胀一次。""" - phone = "13800001011" - token = _login(client, phone) - - r = client.post("/api/v1/signin/boost", json={}, headers=_auth(token)) - assert r.status_code == 409 - - r = client.post("/api/v1/signin", headers=_auth(token)) - assert r.status_code == 200, r.text - first_coin = r.json()["coin_awarded"] - - with SessionLocal() as db: - user = get_user_by_phone(db, phone) - assert user is not None - uid = user.id - - extra = json.dumps({"reward_scene": "signin_boost", "ad_session_id": "signin-session-1"}) - r = client.get( - "/api/v1/ad/pangle-callback", - params=_signed_ad_callback(uid, "signin-boost-trans-1", extra=extra, ecpm="200"), - ) - assert r.status_code == 200, r.text - assert r.json() == {"is_verify": True, "reason": 0} - - r = client.post("/api/v1/signin/boost", json={"ad_ref_id": "signin-boost-trans-1"}, headers=_auth(token)) - assert r.status_code == 200, r.text - body = r.json() - assert body["coin_awarded"] == SIGNIN_BOOST_COIN - assert body["coin_balance"] == first_coin + SIGNIN_BOOST_COIN - - r = client.get( - "/api/v1/ad/pangle-callback", - params=_signed_ad_callback(uid, "signin-boost-trans-2", extra=extra, ecpm="200"), - ) - assert r.status_code == 200, r.text - - r = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token)) - types = [t["biz_type"] for t in r.json()["items"]] - assert "signin" in types - assert "signin_boost" in types - def test_task_claim_flow(client) -> None: """打开消息提醒=可重复任务:每次领取金额减半(750/375/188),claimed 恒 False,余额累加。"""