diff --git a/alembic/versions/coupon_daily_completion_table.py b/alembic/versions/coupon_daily_completion_table.py new file mode 100644 index 0000000..3c547c6 --- /dev/null +++ b/alembic/versions/coupon_daily_completion_table.py @@ -0,0 +1,58 @@ +"""coupon daily completion table(今日跑完整轮领券 → 首页置灰源) + +Revision ID: coupon_daily_completion +Revises: f8d3b1e60a27 +Create Date: 2026-06-10 00:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "coupon_daily_completion" +down_revision: str | Sequence[str] | None = "f8d3b1e60a27" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "coupon_daily_completion", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("device_id", sa.String(length=64), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("complete_date", sa.Date(), nullable=False), + sa.Column("trace_id", sa.String(length=64), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("device_id", "complete_date", name="uq_coupon_completion_device_date"), + ) + with op.batch_alter_table("coupon_daily_completion", schema=None) as batch_op: + batch_op.create_index( + batch_op.f("ix_coupon_daily_completion_user_id"), ["user_id"], unique=False + ) + batch_op.create_index( + batch_op.f("ix_coupon_daily_completion_trace_id"), ["trace_id"], unique=False + ) + + +def downgrade() -> None: + with op.batch_alter_table("coupon_daily_completion", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_coupon_daily_completion_trace_id")) + batch_op.drop_index(batch_op.f("ix_coupon_daily_completion_user_id")) + op.drop_table("coupon_daily_completion") diff --git a/alembic/versions/f8d3b1e60a27_add_debug_trace_and_merge_heads.py b/alembic/versions/f8d3b1e60a27_add_debug_trace_and_merge_heads.py new file mode 100644 index 0000000..5e5ed9a --- /dev/null +++ b/alembic/versions/f8d3b1e60a27_add_debug_trace_and_merge_heads.py @@ -0,0 +1,49 @@ +"""add user.debug_trace_enabled + comparison_record.trace_url + +调试链接权限功能: + - user.debug_trace_enabled:运营后台给指定用户开「复制调试链接」权限 + - comparison_record.trace_url:比价记录页「复制调试链接」的数据(pricebot done 帧给, + dir 名含落盘时分秒、前端/server 拼不出,必须落库) + +注:本迁移最初基于旧 main(#31,双 head)写、down_revision 曾指向那对父;但 #33 已用 +a8c47fc4dc39 合并了那对双 head,故改为线性接在 a8c47fc4dc39 之后、只负责加两列 +(避免与 a8c47fc4dc39 平行再造一个双 head)。 + +Revision ID: f8d3b1e60a27 +Revises: a8c47fc4dc39 +Create Date: 2026-06-10 02:40:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "f8d3b1e60a27" +down_revision: Union[str, Sequence[str], None] = "a8c47fc4dc39" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # sa.false() 渲染成 PG 的 false / SQLite 的 0,两端兼容(避免字符串 "false" 在 SQLite 上存歪) + op.add_column( + "user", + sa.Column( + "debug_trace_enabled", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + ) + op.add_column( + "comparison_record", + sa.Column("trace_url", sa.String(length=512), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("comparison_record", "trace_url") + op.drop_column("user", "debug_trace_enabled") diff --git a/alembic/versions/onboarding_completion_table.py b/alembic/versions/onboarding_completion_table.py new file mode 100644 index 0000000..bd6f725 --- /dev/null +++ b/alembic/versions/onboarding_completion_table.py @@ -0,0 +1,39 @@ +"""onboarding_completion table (新手引导完成标记,按 user+device 去重) + +Revision ID: onboarding_completion +Revises: coupon_daily_completion +Create Date: 2026-06-10 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'onboarding_completion' +down_revision: Union[str, Sequence[str], None] = 'coupon_daily_completion' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # (账号, 设备) 一条完成标记;device_id = 客户端硬件级 ANDROID_ID(跨卸载重装稳定)。 + op.create_table( + 'onboarding_completion', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('device_id', sa.String(length=64), nullable=False), + sa.Column('completed_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'device_id', name='uq_onboarding_user_device'), + ) + with op.batch_alter_table('onboarding_completion', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_onboarding_completion_user_id'), ['user_id'], unique=False) + + +def downgrade() -> None: + with op.batch_alter_table('onboarding_completion', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_onboarding_completion_user_id')) + op.drop_table('onboarding_completion') diff --git a/app/admin/main.py b/app/admin/main.py index 74a06bb..b5948a9 100644 --- a/app/admin/main.py +++ b/app/admin/main.py @@ -13,6 +13,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from app.admin.routers.ad_audit import router as ad_audit_router from app.admin.routers.admins import router as admins_router from app.admin.routers.audit import router as audit_router from app.admin.routers.auth import router as auth_router @@ -21,6 +22,7 @@ from app.admin.routers.dashboard import router as dashboard_router from app.admin.routers.ops_stat_config import router as ops_stat_config_router from app.admin.routers.feedback import router as feedback_router from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router +from app.admin.routers.price_report import router as price_report_router from app.admin.routers.users import router as users_router from app.admin.routers.wallet import router as wallet_router from app.admin.routers.withdraw import router as withdraw_router @@ -80,7 +82,9 @@ admin_app.include_router(ops_marquee_seed_router) admin_app.include_router(users_router) admin_app.include_router(wallet_router) admin_app.include_router(withdraw_router) +admin_app.include_router(price_report_router) admin_app.include_router(feedback_router) admin_app.include_router(admins_router) admin_app.include_router(audit_router) admin_app.include_router(config_router) +admin_app.include_router(ad_audit_router) diff --git a/app/admin/repositories/ad_audit.py b/app/admin/repositories/ad_audit.py new file mode 100644 index 0000000..c882b12 --- /dev/null +++ b/app/admin/repositories/ad_audit.py @@ -0,0 +1,168 @@ +"""看广告金币审计:复算 expected_coin 并与实发对比。 + +只读。复用 [app.core.rewards] 的公式函数(不另写公式,避免与正式发奖口径漂移): +- 看视频:每条 granted = 1 份,第 N 份 = 当日该用户 granted 的 reward_video 顺序号 + (与 ad_reward.grant_ad_reward 里 `_granted_today + 1` 一致)。 +- 信息流:每条按 unit_count 份逐份累加,LT 序号 = 当日该用户已 granted 份数累计 + (与 ad_feed_reward._unit_reward_total 的 existing_units 一致)。 + +非 granted(capped/ecpm_missing)不占用份序号、应发恒 0,据此校验闸口是否确实没发。 +""" +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core import rewards +from app.models.ad_feed_reward import AdFeedRewardRecord +from app.models.ad_reward import AdRewardRecord +from app.repositories.ad_feed_reward import FEED_REWARD_UNIT_SECONDS + + +def _reward_video_rows( + db: Session, *, date: str, user_id: int | None +) -> list[dict]: + """看视频记录复算。按 (user_id, created_at) 升序还原当日第 N 份。""" + stmt = ( + select(AdRewardRecord) + .where( + AdRewardRecord.reward_date == date, + AdRewardRecord.reward_scene == "reward_video", + ) + .order_by(AdRewardRecord.user_id, AdRewardRecord.created_at) + ) + if user_id is not None: + stmt = stmt.where(AdRewardRecord.user_id == user_id) + + granted_n: dict[int, int] = {} # user_id -> 已 granted 份数 + rows: list[dict] = [] + for rec in db.execute(stmt).scalars(): + if rec.status == "granted": + nth = granted_n.get(rec.user_id, 0) + 1 + granted_n[rec.user_id] = nth + expected = rewards.calculate_ad_reward_coin(rec.ecpm_raw, nth) + rows.append({ + "scene": "reward_video", + "record_id": rec.id, + "user_id": rec.user_id, + "created_at": rec.created_at, + "status": rec.status, + "ecpm": rec.ecpm_raw, + "ecpm_factor": rewards.ad_ecpm_factor(rewards.parse_ecpm_yuan(rec.ecpm_raw)), + "units": 1, + "lt_index_start": nth, + "lt_index_end": nth, + "lt_factor_start": rewards.ad_lt_factor(nth), + "lt_factor_end": rewards.ad_lt_factor(nth), + "expected_coin": expected, + "actual_coin": rec.coin, + "matched": expected == rec.coin, + }) + else: + # capped / ecpm_missing:不发金币,校验实发确为 0 + rows.append({ + "scene": "reward_video", + "record_id": rec.id, + "user_id": rec.user_id, + "created_at": rec.created_at, + "status": rec.status, + "ecpm": rec.ecpm_raw, + "ecpm_factor": None, + "units": 1, + "lt_index_start": None, + "lt_index_end": None, + "lt_factor_start": None, + "lt_factor_end": None, + "expected_coin": 0, + "actual_coin": rec.coin, + "matched": rec.coin == 0, + }) + return rows + + +def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]: + """信息流记录复算。granted 记录逐份累加,LT 序号沿用当日累计份数。""" + stmt = ( + select(AdFeedRewardRecord) + .where(AdFeedRewardRecord.reward_date == date) + .order_by(AdFeedRewardRecord.user_id, AdFeedRewardRecord.created_at) + ) + if user_id is not None: + stmt = stmt.where(AdFeedRewardRecord.user_id == user_id) + + granted_units: dict[int, int] = {} # user_id -> 已 granted 份数累计 + rows: list[dict] = [] + for rec in db.execute(stmt).scalars(): + if rec.status == "granted": + existing = granted_units.get(rec.user_id, 0) + units = rec.unit_count + expected = sum( + rewards.calculate_ad_reward_coin(rec.ecpm_raw, existing + offset) + for offset in range(1, units + 1) + ) + granted_units[rec.user_id] = existing + units + start = existing + 1 if units > 0 else None + end = existing + units if units > 0 else None + rows.append({ + "scene": "feed", + "record_id": rec.id, + "user_id": rec.user_id, + "created_at": rec.created_at, + "status": rec.status, + "ecpm": rec.ecpm_raw, + "ecpm_factor": rewards.ad_ecpm_factor(rewards.parse_ecpm_yuan(rec.ecpm_raw)), + "units": units, + "lt_index_start": start, + "lt_index_end": end, + "lt_factor_start": rewards.ad_lt_factor(start) if start else None, + "lt_factor_end": rewards.ad_lt_factor(end) if end else None, + "expected_coin": expected, + "actual_coin": rec.coin, + "matched": expected == rec.coin, + }) + else: + rows.append({ + "scene": "feed", + "record_id": rec.id, + "user_id": rec.user_id, + "created_at": rec.created_at, + "status": rec.status, + "ecpm": rec.ecpm_raw, + "ecpm_factor": None, + "units": rec.unit_count, + "lt_index_start": None, + "lt_index_end": None, + "lt_factor_start": None, + "lt_factor_end": None, + "expected_coin": 0, + "actual_coin": rec.coin, + "matched": rec.coin == 0, + }) + return rows + + +def ad_coin_audit( + db: Session, *, date: str, user_id: int | None, scene: str | None, limit: int +) -> list[dict]: + """返回当日发奖复算明细,按 created_at 倒序(最新在前)截断到 limit。 + + scene: None=两类都要 / "reward_video" / "feed"。 + 份序号在截断前已基于全天数据算好,故 limit 只影响展示条数、不影响 expected 复算正确性。 + """ + rows: list[dict] = [] + if scene in (None, "reward_video"): + rows.extend(_reward_video_rows(db, date=date, user_id=user_id)) + if scene in (None, "feed"): + rows.extend(_feed_rows(db, date=date, user_id=user_id)) + rows.sort(key=lambda r: r["created_at"], reverse=True) + return rows[:limit] + + +def formula_snapshot() -> dict: + """当前公式参数快照(给前端展示规则参照)。直接读 rewards 常量,与发奖同源。""" + return { + "coin_per_yuan": rewards.COIN_PER_YUAN, + "feed_unit_seconds": FEED_REWARD_UNIT_SECONDS, + "ecpm_factor_tiers": [list(t) for t in rewards.AD_ECPM_FACTOR_TABLE], + "lt_factor_tiers": [list(t) for t in rewards.AD_LT_FACTOR_TABLE], + } diff --git a/app/admin/repositories/mutations.py b/app/admin/repositories/mutations.py index 14e3cba..1f7d230 100644 --- a/app/admin/repositories/mutations.py +++ b/app/admin/repositories/mutations.py @@ -8,9 +8,13 @@ set_user_status / update_feedback_status 支持 commit=False,让 router 把"业 """ from __future__ import annotations +from datetime import datetime + from sqlalchemy.orm import Session +from app.core.rewards import CN_TZ from app.models.feedback import Feedback +from app.models.price_report import PriceReport from app.models.user import User @@ -24,6 +28,20 @@ def set_user_status(db: Session, user: User, *, status: str, commit: bool = True return user +def set_user_debug_trace( + db: Session, user: User, *, enabled: bool, commit: bool = True +) -> User: + """开关用户「复制调试链接」权限。同 set_user_status:支持 commit=False 让 router 把 + 业务写 + 审计写放进同一事务。""" + user.debug_trace_enabled = enabled + if commit: + db.commit() + db.refresh(user) + else: + db.flush() + return user + + def update_feedback_status( db: Session, feedback: Feedback, *, status: str, commit: bool = True ) -> Feedback: @@ -34,3 +52,30 @@ def update_feedback_status( else: db.flush() return feedback + + +def review_price_report( + db: Session, + report: PriceReport, + *, + status: str, + reward_coins: int | None = None, + reject_reason: str | None = None, + commit: bool = True, +) -> PriceReport: + """审核上报:置 approved/rejected + 记审核时间;通过填 reward_coins、拒绝填 reject_reason。 + + 发金币(wallet.grant_coins)不在这里——由 router 在同一事务里调,涉钱逻辑不重写(见模块头注释)。 + """ + report.status = status + if reward_coins is not None: + report.reward_coins = reward_coins + if reject_reason is not None: + report.reject_reason = reject_reason + report.reviewed_at = datetime.now(CN_TZ).replace(tzinfo=None) # 北京 wall-clock,同 created_at + if commit: + db.commit() + db.refresh(report) + else: + db.flush() + return report diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index 9601653..ce88603 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -14,6 +14,7 @@ from sqlalchemy.orm import Session from app.models.admin import AdminAuditLog from app.models.comparison import ComparisonRecord from app.models.feedback import Feedback +from app.models.price_report import PriceReport from app.models.user import User from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder @@ -395,3 +396,34 @@ def get_user_overview(db: Session, user_id: int) -> dict | None: ).scalar_one(), "feedback_total": _count(Feedback, Feedback.user_id == user_id), } + + +def list_price_reports( + db: Session, + *, + status: str | None = None, + user_id: int | None = None, + limit: int = 20, + cursor: int | None = None, +) -> tuple[list[PriceReport], int | None]: + """上报更低价列表(admin 全量,可按状态/用户筛)。游标同 feedback:id 倒序。""" + stmt = select(PriceReport) + if status: + stmt = stmt.where(PriceReport.status == status) + if user_id is not None: + stmt = stmt.where(PriceReport.user_id == user_id) + return cursor_paginate(db, stmt, PriceReport.id, limit=limit, cursor=cursor) + + +def price_report_summary(db: Session) -> dict: + """上报审核台各状态计数(待审核/已通过/已拒绝/合计)。""" + rows = db.execute( + select(PriceReport.status, func.count(PriceReport.id)).group_by(PriceReport.status) + ).all() + by_status = {status: int(count) for status, count in rows} + return { + "pending": by_status.get("pending", 0), + "approved": by_status.get("approved", 0), + "rejected": by_status.get("rejected", 0), + "total": sum(by_status.values()), + } diff --git a/app/admin/routers/ad_audit.py b/app/admin/routers/ad_audit.py new file mode 100644 index 0000000..fd4b057 --- /dev/null +++ b/app/admin/routers/ad_audit.py @@ -0,0 +1,44 @@ +"""admin 看广告金币审计:只读对账,核对发奖金币是否按公式计算。 + +任意已登录 admin 可看(只读,不涉及资金操作)。复算逻辑在 app/admin/repositories/ad_audit.py。 +""" +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, Query + +from app.admin.deps import AdminDb, get_current_admin +from app.admin.repositories import ad_audit +from app.admin.schemas.ad_audit import AdCoinAuditOut, AdCoinAuditRow, AdCoinFormulaOut +from app.core.rewards import cn_today + +router = APIRouter( + prefix="/admin/api/ad-coin-audit", + tags=["admin-ad-coin-audit"], + dependencies=[Depends(get_current_admin)], +) + + +@router.get("", response_model=AdCoinAuditOut, summary="看广告金币公式审计(复算对比)") +def get_ad_coin_audit( + db: AdminDb, + date: Annotated[str | None, Query(description="北京时间 YYYY-MM-DD,默认今天")] = None, + user_id: Annotated[int | None, Query(description="只看某用户;不传=全部用户")] = None, + scene: Annotated[ + str | None, Query(description="reward_video / feed;不传=两类都要") + ] = None, + limit: Annotated[int, Query(ge=1, le=500)] = 100, +) -> AdCoinAuditOut: + audit_date = date or cn_today().isoformat() + rows = ad_audit.ad_coin_audit( + db, date=audit_date, user_id=user_id, scene=scene, limit=limit, + ) + items = [AdCoinAuditRow(**r) for r in rows] + return AdCoinAuditOut( + date=audit_date, + formula=AdCoinFormulaOut(**ad_audit.formula_snapshot()), + total=len(items), + mismatch_count=sum(1 for it in items if not it.matched), + items=items, + ) diff --git a/app/admin/routers/price_report.py b/app/admin/routers/price_report.py new file mode 100644 index 0000000..b29f271 --- /dev/null +++ b/app/admin/routers/price_report.py @@ -0,0 +1,103 @@ +"""admin 上报更低价审核:列表 + 统计(读)+ 通过(发金币)/拒绝(写,带审计)。 + +数据由客户端 POST /api/v1/report 写入 price_report 表(提交即 pending);本路由是运营后台 +对它的人工审核窗口。**通过** → 给上报用户钱包发固定金币(PRICE_REPORT_REWARD_COINS): +改状态 + 发金币(wallet.grant_coins)+ 审计同一事务一起 commit(原子,仿 users.grant_user_coins), +绝不只改状态不发钱或反之。客户端轮询 GET /api/v1/report/records 自动看到结果(无需推送)。 +""" +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query, Request + +from app.admin.audit import write_audit +from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role +from app.admin.repositories import mutations, queries +from app.admin.schemas.common import CursorPage, OkResponse +from app.admin.schemas.price_report import ( + PriceReportOut, + PriceReportRejectRequest, + PriceReportSummary, +) +from app.core.rewards import PRICE_REPORT_REWARD_COINS +from app.models.admin import AdminUser +from app.models.price_report import PriceReport +from app.repositories import wallet as wallet_repo + +router = APIRouter( + prefix="/admin/api/price-reports", + tags=["admin-price-report"], + dependencies=[Depends(get_current_admin)], +) + + +@router.get("", response_model=CursorPage[PriceReportOut], summary="上报更低价列表(筛选+分页)") +def list_price_reports( + db: AdminDb, + status: Annotated[str | None, Query()] = None, + user_id: Annotated[int | None, Query()] = None, + limit: Annotated[int, Query(ge=1, le=100)] = 20, + cursor: Annotated[int | None, Query()] = None, +) -> CursorPage[PriceReportOut]: + items, next_cursor = queries.list_price_reports( + db, status=status, user_id=user_id, limit=limit, cursor=cursor, + ) + return CursorPage( + items=[PriceReportOut.model_validate(r) for r in items], next_cursor=next_cursor, + ) + + +@router.get("/summary", response_model=PriceReportSummary, summary="上报审核统计(各状态计数)") +def price_report_summary(db: AdminDb) -> PriceReportSummary: + return PriceReportSummary.model_validate(queries.price_report_summary(db)) + + +@router.post("/{report_id}/approve", response_model=OkResponse, summary="通过上报(发固定金币)") +def approve_price_report( + report_id: int, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> OkResponse: + rep = db.get(PriceReport, report_id) + if rep is None: + raise HTTPException(status_code=404, detail="上报记录不存在") + if rep.status != "pending": + raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作") + coins = PRICE_REPORT_REWARD_COINS + # 改状态 + 发金币 + 审计同一事务(commit=False),最后一起 commit:改了就有痕、发了就留账 + mutations.review_price_report(db, rep, status="approved", reward_coins=coins, commit=False) + wallet_repo.grant_coins( + db, rep.user_id, coins, + biz_type="price_report_reward", ref_id=str(rep.id), remark="上报更低价审核通过", + ) + write_audit( + db, admin, action="price_report.approve", target_type="price_report", target_id=report_id, + detail={"reward_coins": coins, "user_id": rep.user_id}, ip=get_client_ip(request), commit=False, + ) + db.commit() + return OkResponse() + + +@router.post("/{report_id}/reject", response_model=OkResponse, summary="拒绝上报") +def reject_price_report( + report_id: int, + body: PriceReportRejectRequest, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> OkResponse: + rep = db.get(PriceReport, report_id) + if rep is None: + raise HTTPException(status_code=404, detail="上报记录不存在") + if rep.status != "pending": + raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作") + reason = body.reason.strip() + mutations.review_price_report(db, rep, status="rejected", reject_reason=reason, commit=False) + write_audit( + db, admin, action="price_report.reject", target_type="price_report", target_id=report_id, + detail={"reason": reason, "user_id": rep.user_id}, ip=get_client_ip(request), commit=False, + ) + db.commit() + return OkResponse() diff --git a/app/admin/routers/users.py b/app/admin/routers/users.py index 6c03f9a..6da09b9 100644 --- a/app/admin/routers/users.py +++ b/app/admin/routers/users.py @@ -13,6 +13,7 @@ from app.admin.schemas.user import ( AdminUserListItem, AdminUserOverview, GrantCoinsRequest, + SetDebugTraceRequest, SetUserStatusRequest, ) from app.models.admin import AdminUser @@ -77,6 +78,28 @@ def set_user_status( return OkResponse() +@router.post("/{user_id}/debug-trace", response_model=OkResponse, summary="开关调试链接权限") +def set_user_debug_trace( + user_id: int, + body: SetDebugTraceRequest, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> OkResponse: + user = user_repo.get_user_by_id(db, user_id) + if user is None: + raise HTTPException(status_code=404, detail="用户不存在") + before = user.debug_trace_enabled + # 业务写 + 审计写同一事务(commit=False),最后一起 commit(同 set_user_status) + mutations.set_user_debug_trace(db, user, enabled=body.enabled, commit=False) + write_audit( + db, admin, action="user.debug_trace.set", target_type="user", target_id=user_id, + detail={"before": before, "after": body.enabled}, ip=get_client_ip(request), commit=False, + ) + db.commit() + return OkResponse() + + @router.post("/{user_id}/coins", response_model=OkResponse, summary="手动增减金币(带审计)") def grant_user_coins( user_id: int, diff --git a/app/admin/schemas/ad_audit.py b/app/admin/schemas/ad_audit.py new file mode 100644 index 0000000..cd5f949 --- /dev/null +++ b/app/admin/schemas/ad_audit.py @@ -0,0 +1,57 @@ +"""看广告金币审计 schemas。 + +只读对账视图:把"看视频"(ad_reward_record)和"信息流"(ad_feed_reward_record)两类发奖记录, +用与正式发奖相同的公式 [app.core.rewards.calculate_ad_reward_coin] 复算一遍 expected_coin, +和实际入账的 actual_coin 对比,核对金币公式是否生效。字段 snake_case、金额按金币整数。 +""" +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field + + +class AdCoinAuditRow(BaseModel): + """单条发奖记录的复算明细。""" + + scene: str = Field(..., description="reward_video(看视频) / feed(信息流)") + record_id: int = Field(..., description="对应记录表主键") + user_id: int + created_at: datetime + status: str = Field(..., description="granted / capped / ecpm_missing") + ecpm: str | None = Field(None, description="本次采用的 eCPM 原始值(分/千次展示,SDK getEcpm 原值)") + ecpm_factor: float | None = Field(None, description="因子1(eCPM 档)") + units: int = Field(..., description="折算份数:看视频恒为 1;信息流 = 满 10 秒的份数") + lt_index_start: int | None = Field(None, description="本条占用的当日第几份(起)") + lt_index_end: int | None = Field(None, description="本条占用的当日第几份(止);看视频 = 起") + lt_factor_start: float | None = Field(None, description="因子2(LT)起值") + lt_factor_end: float | None = Field(None, description="因子2(LT)止值;看视频 = 起值") + expected_coin: int = Field(..., description="按公式复算应发金币") + actual_coin: int = Field(..., description="实际入账金币") + matched: bool = Field(..., description="复算与实发是否一致(capped/ecpm_missing 校验是否确为 0)") + + +class AdCoinFormulaOut(BaseModel): + """当前金币公式参数(给前端展示规则参照)。""" + + description: str = Field( + "eCPM元 = getEcpm分 ÷ 100;单份金币 = round(eCPM元 ÷ 1000 × 因子1 × 因子2 × coin_per_yuan);" + "因子1 按 eCPM元 判档(阈值 100/200/400 元)", + description="公式说明", + ) + coin_per_yuan: int = Field(..., description="金币:元 汇率") + ecpm_unit: str = Field("分/千次展示(SDK getEcpm 原值)", description="eCPM 口径") + feed_unit_seconds: int = Field(..., description="信息流每多少秒折 1 份") + # [(因子值, 区间下限, 区间上限或 null)] + ecpm_factor_tiers: list[tuple[float, int, int | None]] = Field(..., description="因子1 档位表") + lt_factor_tiers: list[tuple[float, int, int | None]] = Field(..., description="因子2 LT 档位表") + + +class AdCoinAuditOut(BaseModel): + """审计响应:公式参照 + 命中条数 + 明细。""" + + date: str = Field(..., description="审计日期(北京时间 YYYY-MM-DD)") + formula: AdCoinFormulaOut + total: int = Field(..., description="返回的明细条数") + mismatch_count: int = Field(..., description="其中 matched=false 的条数(=0 说明公式全部生效)") + items: list[AdCoinAuditRow] diff --git a/app/admin/schemas/price_report.py b/app/admin/schemas/price_report.py new file mode 100644 index 0000000..30ea8ad --- /dev/null +++ b/app/admin/schemas/price_report.py @@ -0,0 +1,46 @@ +"""admin 上报更低价审核 schemas。""" +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class PriceReportOut(BaseModel): + """admin 列表项:price_report 全字段(审核要看的快照 + 截图 + 状态 + 奖励)。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + user_id: int + comparison_record_id: int | None = None + # 选中比价记录的快照 + store_name: str | None = None + dish_summary: str | None = None + original_platform_id: str | None = None + original_platform_name: str | None = None + original_price_cents: int | None = None + # 用户上报的更低价 + reported_platform_id: str + reported_platform_name: str + reported_price_cents: int + images: list[str] = [] + # 审核 + status: str + reject_reason: str | None = None + reward_coins: int | None = None + reviewed_at: datetime | None = None + created_at: datetime + + +class PriceReportRejectRequest(BaseModel): + reason: str = Field(min_length=1, max_length=256, description="拒绝理由,用户端记录页会看到") + + +class PriceReportSummary(BaseModel): + """审核台顶部各状态计数。""" + + pending: int + approved: int + rejected: int + total: int diff --git a/app/admin/schemas/user.py b/app/admin/schemas/user.py index 46a548f..304c8b7 100644 --- a/app/admin/schemas/user.py +++ b/app/admin/schemas/user.py @@ -15,6 +15,7 @@ class AdminUserListItem(BaseModel): nickname: str | None = None register_channel: str status: str + debug_trace_enabled: bool = False wechat_openid: str | None = None created_at: datetime last_login_at: datetime @@ -45,3 +46,7 @@ class SetUserStatusRequest(BaseModel): status: Literal["active", "disabled"] = Field( ..., description="active=解封 / disabled=封禁(注销 deleted 不走此接口)" ) + + +class SetDebugTraceRequest(BaseModel): + enabled: bool = Field(..., description="是否给该用户开「复制调试链接」权限") diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index 3c21819..0c7e351 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -304,10 +304,18 @@ def test_grant(user: CurrentUser, db: DbSession, payload: TestGrantIn | None = N db.commit() db.refresh(rec) else: + # 优先用客户端按 ad_session_id 上报的真实 eCPM(走与正式发奖相同的公式); + # 取不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍能验出非零金币。 + ad_session_id = payload.ad_session_id if payload is not None else None + ecpm_val = "200" + if ad_session_id: + ecpm_rec = crud_ecpm.find_by_session(db, user_id=user.id, ad_session_id=ad_session_id) + if ecpm_rec is not None and rewards.parse_ecpm_fen(ecpm_rec.ecpm_raw) > 0: + ecpm_val = ecpm_rec.ecpm_raw try: rec = crud_ad.grant_ad_reward( - db, user.id, trans_id, ecpm="200", reward_name="测试发奖", - raw="client debug test-grant ecpm=200", + db, user.id, trans_id, ecpm=ecpm_val, ad_session_id=ad_session_id, + reward_name="测试发奖", raw=f"client debug test-grant ecpm={ecpm_val}", ) except crud_ad.UnknownUserError as e: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py index fd31641..2ba8521 100644 --- a/app/api/v1/auth.py +++ b/app/api/v1/auth.py @@ -19,6 +19,7 @@ from app.core.ratelimit import rate_limit from app.core.security import TokenError, decode_token, issue_token_pair from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone from app.integrations.sms import SmsError, send_code, verify_code +from app.repositories import onboarding as onboarding_repo from app.repositories import user as user_repo from app.schemas.auth import ( JverifyLoginRequest, @@ -37,12 +38,13 @@ logger = logging.getLogger("shagua.auth") router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) -def _login_response(db, user) -> TokenWithUser: - """登录类接口共用的响应组装。""" +def _login_response(user, *, onboarding_completed: bool) -> TokenWithUser: + """登录类接口共用的响应组装。onboarding_completed 据登录请求的 device_id 算好后传入。""" tokens = issue_token_pair(user.id) return TokenWithUser( **tokens, user=UserOut.model_validate(user), + onboarding_completed=onboarding_completed, ) @@ -66,8 +68,9 @@ def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser: if user.status != "active": raise HTTPException(status_code=403, detail="account disabled") - logger.info("jverify_login ok user_id=%d phone=%s", user.id, mask_phone(phone)) - return _login_response(db, user) + completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id) + logger.info("jverify_login ok user_id=%d phone=%s onboarded=%s", user.id, mask_phone(phone), completed) + return _login_response(user, onboarding_completed=completed) # ===================== 短信登录 ===================== @@ -103,8 +106,9 @@ def sms_login(req: SmsLoginRequest, db: DbSession) -> TokenWithUser: if user.status != "active": raise HTTPException(status_code=403, detail="account disabled") - logger.info("sms_login ok user_id=%d phone=%s", user.id, mask_phone(req.phone)) - return _login_response(db, user) + completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id) + logger.info("sms_login ok user_id=%d phone=%s onboarded=%s", user.id, mask_phone(req.phone), completed) + return _login_response(user, onboarding_completed=completed) # ===================== Refresh ===================== diff --git a/app/api/v1/compare_record.py b/app/api/v1/compare_record.py index 9c8f55b..8e527dc 100644 --- a/app/api/v1/compare_record.py +++ b/app/api/v1/compare_record.py @@ -77,10 +77,12 @@ def list_records( items, next_cursor = crud_compare.list_records( db, user.id, limit=limit, cursor=cursor ) - return ComparisonRecordPage( - items=[ComparisonRecordOut.model_validate(it) for it in items], - next_cursor=next_cursor, - ) + outs = [ComparisonRecordOut.model_validate(it) for it in items] + # 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url(列表页「复制调试链接」靠它) + if not user.debug_trace_enabled: + for o in outs: + o.trace_url = None + return ComparisonRecordPage(items=outs, next_cursor=next_cursor) @router.get( @@ -94,4 +96,12 @@ def get_record( rec = crud_compare.get_record(db, user.id, record_id) if rec is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="record not found") - return ComparisonRecordDetailOut.model_validate(rec) + out = ComparisonRecordDetailOut.model_validate(rec) + # 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url。 + # ⚠️ raw_payload 是上报体全量(model_dump),里面也藏着一份 trace_url,必须一并抹掉—— + # 否则无权限用户从详情接口的 raw_payload 绕过权限闸拿到 trace_url。 + if not user.debug_trace_enabled: + out.trace_url = None + if isinstance(out.raw_payload, dict): + out.raw_payload.pop("trace_url", None) + return out diff --git a/app/api/v1/coupon.py b/app/api/v1/coupon.py index 9051f92..e44adb5 100644 --- a/app/api/v1/coupon.py +++ b/app/api/v1/coupon.py @@ -23,7 +23,11 @@ from app.core.config import settings from app.core.pricebot_router import pick_pricebot from app.db.session import SessionLocal from app.repositories import coupon_state as coupon_repo -from app.schemas.coupon_state import CouponPromptDismissIn, CouponPromptShouldShowOut +from app.schemas.coupon_state import ( + CouponCompletedTodayOut, + CouponPromptDismissIn, + CouponPromptShouldShowOut, +) logger = logging.getLogger("shagua.coupon") @@ -76,6 +80,14 @@ def _record_claims_blocking( coupon_repo.record_claims(db, device_id, user_id, trace_id, results) +def _mark_completed_blocking( + device_id: str, user_id: int | None, trace_id: str | None +) -> None: + """独立 session 写"今日已跑完整轮"(到 done 帧那刻调,best-effort 不阻塞领券)。""" + with SessionLocal() as db: + coupon_repo.mark_completed_today(db, device_id, user_id, trace_id) + + @router.post("/step", summary="领券任务步进 (透传到 pricebot)") async def coupon_step( request: Request, @@ -160,6 +172,19 @@ async def coupon_step( except Exception as e: # noqa: BLE001 logger.warning("coupon claim write failed: %s", e) + # 整轮跑完(done 帧)→ 记一条"今日已完成",首页「去领取」卡据此置灰。 + # pricebot 把中途单券 done 改写成 wait+continue=true,只有整套全跑完那帧才保留 + # command=="done"(见 pricebot main.py),故 done 已等价"整轮完成"(用户决策 A 方案: + # 到 done 即算,不管单券成败),无需再判 continue。写库失败绝不连累领券返回。 + action = resp_json.get("action") or {} + if device_id and action.get("command") == "done": + try: + await run_in_threadpool( + _mark_completed_blocking, device_id, user_id, trace_id + ) + except Exception as e: # noqa: BLE001 + logger.warning("coupon completion write failed: %s", e) + return resp_json @@ -195,3 +220,19 @@ def coupon_prompt_reset(payload: CouponPromptDismissIn, db: DbSession) -> dict[s 开发设置「重置今日领券弹窗状态」按钮调。MVP 不鉴权,按 device_id。""" coupon_repo.reset_today_engagement(db, payload.device_id) return {"ok": True} + + +@router.get( + "/completed-today", + response_model=CouponCompletedTodayOut, + summary="这台设备今天是否已跑完整轮领券(到 done 帧)", +) +def coupon_completed_today( + device_id: str, db: DbSession +) -> CouponCompletedTodayOut: + """今天这台设备已跑完整轮(到 done)→ completed=true。客户端据此把首页「去领取」 + 卡置灰、不可点(用户决策 A 方案:到 done 即算,不管单券成败)。判断维度 device_id + 必须与领券循环上报的一致(客户端两端都用 ANDROID_ID)。""" + return CouponCompletedTodayOut( + completed=coupon_repo.has_completed_today(db, device_id) + ) diff --git a/app/api/v1/user.py b/app/api/v1/user.py index d9cb31b..5e96d75 100644 --- a/app/api/v1/user.py +++ b/app/api/v1/user.py @@ -16,9 +16,10 @@ from fastapi import APIRouter, File, HTTPException, UploadFile from app.api.deps import CurrentUser, DbSession from app.core import media +from app.repositories import onboarding as onboarding_repo from app.repositories import user as user_repo from app.schemas.auth import UserOut -from app.schemas.user import OkResponse, ProfileUpdateRequest +from app.schemas.user import OkResponse, OnboardingCompleteRequest, ProfileUpdateRequest logger = logging.getLogger("shagua.user") @@ -51,6 +52,17 @@ async def upload_avatar( return UserOut.model_validate(updated) +@router.post("/onboarding/complete", response_model=OkResponse, summary="标记新手引导完成(按 设备+账号)") +def complete_onboarding( + req: OnboardingCompleteRequest, user: CurrentUser, db: DbSession +) -> OkResponse: + """走完新手引导时调一次。按 (当前账号, device_id) 落一条完成标记,跨卸载重装持久。 + 幂等:重复调用不报错。device_id 取客户端硬件级 ANDROID_ID,与登录请求一致。""" + onboarding_repo.mark_completed(db, user_id=user.id, device_id=req.device_id) + logger.info("onboarding complete user_id=%d device_len=%d", user.id, len(req.device_id)) + return OkResponse() + + @router.delete("", response_model=OkResponse, summary="注销账号(软删除)") def delete_account(user: CurrentUser, db: DbSession) -> OkResponse: media.delete_avatar(user.avatar_url) diff --git a/app/core/rewards.py b/app/core/rewards.py index 66449e7..84de0c7 100644 --- a/app/core/rewards.py +++ b/app/core/rewards.py @@ -77,6 +77,12 @@ def record_milestone_reward(milestone: int) -> int: return RECORD_MILESTONES[milestone - 1] +# ===== 上报更低价(人工审核通过发固定金币)===== +# 用户上报"某平台比我们算的最低价更便宜"+ 截图,经运营后台人工审核通过后发放的固定金币奖励。 +# 与广告/任务同量级;固定值(产品 2026-06 定),要调直接改这里;客户端记录页按 reward_coins 显示。 +PRICE_REPORT_REWARD_COINS: int = 1000 + + # ===== 邀请好友(注册即生效,邀请人 + 被邀请人各发金币)===== # 10000 金币 = 1 元,双方各得 1 元。MVP 先用固定常量(不走 app_config)。 INVITE_INVITER_COINS: int = 10000 @@ -92,7 +98,11 @@ INVITE_FP_WINDOW_DAYS: int = 7 # ===== 看激励视频 / 信息流广告发金币 ===== -# 金币数值体系约定:eCPM 单位按"元/千次展示"处理,单次收入 = eCPM / 1000 元。 +# eCPM 取自穿山甲 SDK getShowEcpm().getEcpm(),官方口径单位是【分/千次展示】(不是元! +# csjplatform 文档原文"通过 getEcpm 获取的单位是分")。计算时先 ÷100 转成元; +# 因子1 档位阈值按【元/千次】定(100/200/400 元 = ¥100/¥200/¥400 CPM,产品口径 2026-06-09)。 +# 注:真实 eCPM 一般 <¥100 CPM,故多落最低档 0.1,高档基本不触发——这是产品有意的取舍。 +# 单次展示收益(元) = eCPM元 ÷ 1000(每千次→单次)。 AD_ECPM_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = ( (0.1, 0, 100), (0.3, 101, 200), @@ -108,8 +118,8 @@ AD_LT_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = ( ) -def parse_ecpm_yuan(ecpm: str | int | float | None) -> float: - """解析 eCPM 原始值。当前产品口径:SDK 返回值按"元/千次展示"处理。""" +def parse_ecpm_fen(ecpm: str | int | float | None) -> float: + """解析 eCPM 原始值(穿山甲 getEcpm 原值,单位=分/千次展示)。非法/缺失→0。""" if ecpm is None: return 0.0 try: @@ -119,8 +129,14 @@ def parse_ecpm_yuan(ecpm: str | int | float | None) -> float: return max(0.0, value) +def parse_ecpm_yuan(ecpm: str | int | float | None) -> float: + """eCPM 转成元(getEcpm 原值是分,÷100)。因子档位判定与收益换算都用元。""" + return parse_ecpm_fen(ecpm) / 100.0 + + def ad_ecpm_factor(ecpm_yuan: float) -> float: - """eCPM 档位因子:0-100=0.1,101-200=0.3,201-400=0.4,>400=0.6。""" + """eCPM 档位因子(阈值单位=元/千次):≤100=0.1,101-200=0.3,201-400=0.4,>400=0.6。 + 产品口径(2026-06-09):阈值按元判档;真实 eCPM(<¥100 CPM)多落最低档 0.1。""" if ecpm_yuan > 400: return 0.6 if ecpm_yuan > 200: @@ -142,7 +158,9 @@ def ad_lt_factor(today_count_after_this: int) -> float: def calculate_ad_reward_coin(ecpm: str | int | float | None, today_count_after_this: int) -> int: """按金币数值体系计算单份广告奖励金币。 - 单次奖励(元)=eCPM/1000 × 因子1(eCPM 档) × 因子2(LT);再按 1 元=10000 金币取整。 + eCPM 是穿山甲 getEcpm 原值,单位【分/千次展示】;先 ÷100 转成元(因子判档 + 收益换算都用元)。 + 单次收益(元)= eCPM元 ÷ 1000(每千次→单次) × 因子1(eCPM 元档) × 因子2(LT); + 再按 1 元=10000 金币取整。 """ ecpm_yuan = parse_ecpm_yuan(ecpm) yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(today_count_after_this) diff --git a/app/models/__init__.py b/app/models/__init__.py index 428fa03..4ed0d1b 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -9,12 +9,14 @@ from app.models.comparison import ComparisonRecord # noqa: F401 from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401 from app.models.coupon_state import ( # noqa: F401 CouponClaimRecord, + CouponDailyCompletion, CouponPromptEngagement, ) from app.models.feedback import Feedback # noqa: F401 from app.models.invite import InviteRelation # noqa: F401 from app.models.invite_fingerprint import InviteFingerprint # noqa: F401 from app.models.meituan_coupon import MeituanCoupon # noqa: F401 +from app.models.onboarding import OnboardingCompletion # noqa: F401 from app.models.ops_marquee_seed import OpsMarqueeSeed # noqa: F401 from app.models.ops_stat_config import OpsStatConfig # noqa: F401 from app.models.price_observation import PriceObservation # noqa: F401 diff --git a/app/models/ad_ecpm.py b/app/models/ad_ecpm.py index 28b06f5..0f46b51 100644 --- a/app/models/ad_ecpm.py +++ b/app/models/ad_ecpm.py @@ -35,7 +35,7 @@ class AdEcpmRecord(Base): adn: Mapped[str | None] = mapped_column(String(32), nullable=True) # 实际展示用的代码位(底层 mediation rit,非客户端配置位) slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True) - # 客户端上报的 eCPM 原始字符串(单位:元/千次展示,原样存) + # 客户端上报的 eCPM 原始字符串(单位:分/千次展示,SDK getEcpm 原值,原样存) ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False) # 北京时间日期串 'YYYY-MM-DD',按它等值做"按天聚合"(不在 SQL 里做跨时区 date 比较) report_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False) diff --git a/app/models/comparison.py b/app/models/comparison.py index f1f593c..7a42a4f 100644 --- a/app/models/comparison.py +++ b/app/models/comparison.py @@ -53,6 +53,10 @@ class ComparisonRecord(Base): ) # pricebot 侧 trace_id:关联调试落盘 + 幂等去重键 trace_id: Mapped[str] = mapped_column(String(64), nullable=False) + # 本次比价的公网调试链接(price.shaguabijia.com/traces/{dir}/)。pricebot done 帧给、 + # 客户端上报带上——dir 名含 pricebot 落盘的时分秒,前端/server 都拼不出,必须存。 + # 查看接口按 user.debug_trace_enabled 决定返不返回。旧记录 / 未开上云为 None。 + trace_url: Mapped[str | None] = mapped_column(String(512), nullable=True) # ===== 源平台(发起比价的那家)===== source_platform_id: Mapped[str | None] = mapped_column(String(32), nullable=True) diff --git a/app/models/coupon_state.py b/app/models/coupon_state.py index 4374048..bd2aa2d 100644 --- a/app/models/coupon_state.py +++ b/app/models/coupon_state.py @@ -93,6 +93,49 @@ class CouponClaimRecord(Base): ) +class CouponDailyCompletion(Base): + """按 (device, 自然日) 记"今天是否已跑完整轮领券(到 done 帧)"——首页置灰源。 + + 与 engagement 区别:engagement = 用户表达过意向(点了一键领取/拒绝即记,不管跑没跑完); + completion = 这一轮真跑到了 done(整套流程走完)。首页「去领取」卡据此置灰:跑完了 + 今天就不能再领。判断口径(用户决策 2026-06-10 A 方案):**到 done 即算**,不管单券 + 成败(失败/跳过常是无障碍/环境问题,重复点也补不回来)。判断维度 device_id,与 + engagement/claim 一致。 + """ + + __tablename__ = "coupon_daily_completion" + __table_args__ = ( + # 一台设备一天一条:今天跑完过就置灰。 + UniqueConstraint( + "device_id", "complete_date", + name="uq_coupon_completion_device_date", + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + + device_id: Mapped[str] = mapped_column(String(64), nullable=False) + user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True) + # Asia/Shanghai 自然日。 + complete_date: Mapped[date] = mapped_column(Date, nullable=False) + # 哪次任务跑到 done,回指 pricebot work_logs / 排查。 + trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), + nullable=False, + ) + + def __repr__(self) -> str: # pragma: no cover + return ( + f"" + ) + + class CouponPromptEngagement(Base): """按 (device, 自然日) 记"今天是否对领券引导窗表达过意向"——弹窗频控源。""" diff --git a/app/models/onboarding.py b/app/models/onboarding.py new file mode 100644 index 0000000..9862d93 --- /dev/null +++ b/app/models/onboarding.py @@ -0,0 +1,40 @@ +"""新手引导完成标记(按 设备 + 账号 去重)。 + +产品规则:同一台设备 + 同一个账号,新手引导只跑一次;卸载重装(同设备同账号) +不再触发。本地标记(SharedPreferences)卸载即丢,所以"完成"必须落后端,按 +(user_id, device_id) 唯一一条。 + +⚠️ device_id 这里用客户端的**硬件级稳定标识**(Android `Settings.Secure.ANDROID_ID`), +同签名 app 卸载重装不变、仅恢复出厂才重置——区别于领券/比价用的 per-install device_id +(见 coupon_state,存 SP、重装会变)。换新设备 → 无此行 → 重新走引导。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, Integer, String, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class OnboardingCompletion(Base): + """一台设备 + 一个账号 一条:走完新手引导即写入,登录时据此跳过引导。""" + + __tablename__ = "onboarding_completion" + __table_args__ = ( + # 同账号、同设备只一条:重复 mark / 并发提交靠它幂等。 + UniqueConstraint("user_id", "device_id", name="uq_onboarding_user_device"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + # 登录态用户。引导是登录后才走的,故必填(非空)、进唯一键。 + user_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) + # 硬件级稳定设备标识(ANDROID_ID),卸载重装不变。 + device_id: Mapped[str] = mapped_column(String(64), nullable=False) + completed_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" diff --git a/app/models/user.py b/app/models/user.py index 2f1d672..67d6600 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -11,7 +11,7 @@ from __future__ import annotations from datetime import datetime, timezone -from sqlalchemy import DateTime, Integer, String, func +from sqlalchemy import Boolean, DateTime, Integer, String, false, func from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base @@ -51,6 +51,13 @@ class User(Base): # 账号状态:active / disabled / deleted status: Mapped[str] = mapped_column(String(20), nullable=False, default="active") + # 调试链接权限:开了的用户在比价完成弹窗 + 比价记录页能看到「复制调试链接」按钮 + # (复制 price.shaguabijia.com 的 trace 链接发给开发排障)。运营后台按用户配置; + # /me 与登录响应里带出给前端做条件渲染。默认 false。 + debug_trace_enabled: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default=false() + ) + created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) diff --git a/app/repositories/comparison.py b/app/repositories/comparison.py index e697be6..640ffa0 100644 --- a/app/repositories/comparison.py +++ b/app/repositories/comparison.py @@ -84,6 +84,7 @@ def upsert_record( source_package=payload.source_package, information=payload.information, best_deeplink=payload.best_deeplink, + trace_url=payload.trace_url, total_dish_count=payload.total_dish_count, skipped_dish_count=payload.skipped_dish_count, items=[it.model_dump(exclude_none=True) for it in payload.items], diff --git a/app/repositories/coupon_state.py b/app/repositories/coupon_state.py index e255e9f..c5012b2 100644 --- a/app/repositories/coupon_state.py +++ b/app/repositories/coupon_state.py @@ -13,7 +13,11 @@ from sqlalchemy import delete, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session -from app.models.coupon_state import CouponClaimRecord, CouponPromptEngagement +from app.models.coupon_state import ( + CouponClaimRecord, + CouponDailyCompletion, + CouponPromptEngagement, +) logger = logging.getLogger("shagua.coupon_state") @@ -78,6 +82,47 @@ def reset_today_engagement(db: Session, device_id: str) -> int: return result.rowcount or 0 +# ===== 今日跑完整轮(coupon_daily_completion)===== + +def has_completed_today(db: Session, device_id: str) -> bool: + """这台设备今天是否已跑完整轮领券(到 done 帧)。有 = 首页置灰、不能再领。""" + row = db.execute( + select(CouponDailyCompletion.id).where( + CouponDailyCompletion.device_id == device_id, + CouponDailyCompletion.complete_date == today_cn(), + ) + ).first() + return row is not None + + +def mark_completed_today( + db: Session, device_id: str, user_id: int | None, trace_id: str | None = None +) -> None: + """记今日已跑完整轮。(device, 今天) 唯一,幂等 upsert。到 done 即记,不管单券成败。""" + today = today_cn() + row = db.execute( + select(CouponDailyCompletion).where( + CouponDailyCompletion.device_id == device_id, + CouponDailyCompletion.complete_date == today, + ) + ).scalar_one_or_none() + if row is not None: + if user_id is not None: + row.user_id = user_id + if trace_id is not None: + row.trace_id = trace_id + else: + db.add(CouponDailyCompletion( + device_id=device_id, user_id=user_id, + complete_date=today, trace_id=trace_id, + )) + try: + db.commit() + except IntegrityError: + # 并发下另一请求刚插了同 (device, 日) → 唯一约束撞,回滚忽略(本就幂等)。 + db.rollback() + + # ===== 领券记录(coupon_claim_record)===== def record_claims( diff --git a/app/repositories/onboarding.py b/app/repositories/onboarding.py new file mode 100644 index 0000000..03e51d3 --- /dev/null +++ b/app/repositories/onboarding.py @@ -0,0 +1,35 @@ +"""新手引导完成标记的读写(按 user_id + device_id 去重)。 + +登录时 [is_completed] 判断该 (账号, 设备) 是否走过引导;走完引导时 [mark_completed] 写一条。 +device_id 为空(老客户端 / 取不到 ANDROID_ID)一律按"未完成"处理,即照常走引导,不误跳过。 +""" +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.models.onboarding import OnboardingCompletion + + +def is_completed(db: Session, *, user_id: int, device_id: str) -> bool: + """该 (账号, 设备) 是否已完成新手引导。device_id 为空 → 一律 False。""" + if not device_id: + return False + stmt = select(OnboardingCompletion.id).where( + OnboardingCompletion.user_id == user_id, + OnboardingCompletion.device_id == device_id, + ) + return db.execute(stmt).first() is not None + + +def mark_completed(db: Session, *, user_id: int, device_id: str) -> None: + """走完引导时写入。device_id 为空忽略;同 (账号, 设备) 已存在则幂等(靠唯一约束兜并发)。""" + if not device_id: + return + db.add(OnboardingCompletion(user_id=user_id, device_id=device_id)) + try: + db.commit() + except IntegrityError: + # 并发 / 重复提交撞唯一约束:已有行即视为成功。 + db.rollback() diff --git a/app/schemas/ad.py b/app/schemas/ad.py index e64cd9c..a1cb7e0 100644 --- a/app/schemas/ad.py +++ b/app/schemas/ad.py @@ -46,11 +46,11 @@ class AdRewardStatusOut(BaseModel): class EcpmReportIn(BaseModel): """客户端上报一次广告展示的 eCPM(内部收益统计/对账)。 - user_id 不在 body 里——由 JWT 取(Bearer),防伪造。ecpm 原样上报字符串,后端按元/千次展示处理。 + user_id 不在 body 里——由 JWT 取(Bearer),防伪造。ecpm 原样上报字符串,后端按分/千次展示处理(SDK getEcpm 原值,非元)。 """ ad_type: str = Field(..., description="广告类型:reward_video(激励视频) / draw(Draw 信息流) 等") - ecpm: str = Field(..., description="穿山甲 getShowEcpm().getEcpm() 原始字符串,按元/千次展示处理") + ecpm: str = Field(..., description="穿山甲 getShowEcpm().getEcpm() 原始字符串,按分/千次展示处理(SDK getEcpm 原值,非元)") ad_session_id: str | None = Field( None, min_length=8, max_length=64, description="客户端生成的一次广告会话 id;激励视频 S2S extra 会透传同值", @@ -89,6 +89,11 @@ class TestGrantIn(BaseModel): "reward_video", description="模拟发奖场景:reward_video(普通激励视频) / signin_boost(签到膨胀)", ) + ad_session_id: str | None = Field( + None, min_length=8, max_length=64, + description="本次广告会话 id(与 ecpm-report 同值)。reward_video 场景下据此查回客户端" + "已上报的真实 eCPM 来按公式发奖;查不到或 eCPM≤0 时兜底 200,保证本地联调仍出非零金币", + ) class TestGrantOut(BaseModel): @@ -119,7 +124,7 @@ class FeedRewardIn(BaseModel): ad_session_id: str | None = Field( None, min_length=8, max_length=64, description="客户端生成的一次信息流广告会话 id" ) - ecpm: str = Field(..., description="本条信息流广告 eCPM,按元/千次展示处理") + ecpm: str = Field(..., description="本条信息流广告 eCPM,按分/千次展示处理(SDK getEcpm 原值,非元)") duration_seconds: int = Field(..., ge=0, description="本条广告实际展示/播放秒数") adn: str | None = Field(None, description="实际投放 ADN") slot_id: str | None = Field(None, description="实际展示代码位") diff --git a/app/schemas/auth.py b/app/schemas/auth.py index 2705bba..a5eb077 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -25,6 +25,8 @@ class UserOut(BaseModel): status: str created_at: datetime last_login_at: datetime + # 调试链接权限:前端据此在比价结果弹窗/记录页显示「复制调试链接」按钮。默认 false。 + debug_trace_enabled: bool = False # ===== Token 通用结构 ===== @@ -39,6 +41,11 @@ class TokenPair(BaseModel): class TokenWithUser(TokenPair): user: UserOut + onboarding_completed: bool = Field( + False, + description="该 设备+账号 是否已走完新手引导(据登录请求里的 device_id 计算)。" + "true → 客户端登录后直接进首页,跳过引导。", + ) # ===== 极光一键登录 ===== @@ -46,6 +53,10 @@ class TokenWithUser(TokenPair): class JverifyLoginRequest(BaseModel): login_token: str = Field(..., description="客户端 loginAuth 拿到的 loginToken", min_length=1) operator: str = Field("", description="CM/CU/CT,用于日志,可选") + device_id: str = Field( + "", max_length=64, + description="硬件级设备标识(Android ANDROID_ID),用于新手引导按 设备+账号 去重;空=按未完成处理", + ) # ===== 短信验证码 ===== @@ -63,6 +74,10 @@ class SmsSendResponse(BaseModel): class SmsLoginRequest(BaseModel): phone: str = Field(..., min_length=11, max_length=11, pattern=r"^1\d{10}$") code: str = Field(..., min_length=4, max_length=8) + device_id: str = Field( + "", max_length=64, + description="硬件级设备标识(Android ANDROID_ID),用于新手引导按 设备+账号 去重;空=按未完成处理", + ) # ===== Refresh ===== diff --git a/app/schemas/compare_record.py b/app/schemas/compare_record.py index 7543d4a..2a6ba5f 100644 --- a/app/schemas/compare_record.py +++ b/app/schemas/compare_record.py @@ -67,6 +67,9 @@ class ComparisonRecordIn(BaseModel): status: str | None = Field(None, description="success / failed,可不传由服务端派生") # 最优平台商家/商品深链(客户端从 collectedLinks[best_index] 取);「再次比价」直达用 best_deeplink: str | None = Field(None, description="最优平台深链,再次比价直达") + # pricebot done.params.trace_url 原样上报,落库供记录页「复制调试链接」(dir 名含落盘 + # 时分秒前端拼不出,必须由后端透传)。 + trace_url: str | None = Field(None, description="本次比价公网调试链接") # ===== 读取出参 ===== @@ -79,6 +82,8 @@ class ComparisonRecordOut(BaseModel): id: int business_type: str trace_id: str + # 公网调试链接;仅当 user.debug_trace_enabled 时由端点填充,否则端点层置 None(权限闸)。 + trace_url: str | None = None source_platform_id: str | None = None source_platform_name: str | None = None source_package: str | None = None diff --git a/app/schemas/coupon_state.py b/app/schemas/coupon_state.py index 92043ce..fd66cb8 100644 --- a/app/schemas/coupon_state.py +++ b/app/schemas/coupon_state.py @@ -19,3 +19,9 @@ class CouponPromptShouldShowOut(BaseModel): """切到外卖 App 时是否还应弹领券引导窗。今天已 engage(领或拒)过 → false。""" should_show: bool + + +class CouponCompletedTodayOut(BaseModel): + """这台设备今天是否已跑完整轮领券(到 done 帧)。完成 → 首页「去领取」卡置灰。""" + + completed: bool diff --git a/app/schemas/user.py b/app/schemas/user.py index ebaba24..cbe6469 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -16,5 +16,12 @@ class ProfileUpdateRequest(BaseModel): return v +class OnboardingCompleteRequest(BaseModel): + device_id: str = Field( + ..., min_length=1, max_length=64, + description="硬件级设备标识(Android ANDROID_ID),与登录时一致,用于按 设备+账号 标记引导完成", + ) + + class OkResponse(BaseModel): ok: bool = True diff --git a/data/media/dl.html b/data/media/dl.html index f8ec320..cf1519a 100644 --- a/data/media/dl.html +++ b/data/media/dl.html @@ -62,11 +62,11 @@
💰 省下的钱看得见,还能赚金币提现
- -
Android 安卓版 · 安装包约 24 MB
+ +
Android 安卓版 · 应用商店安全下载
- 安装时如提示「未知来源」,请允许后继续安装。
+ 将前往应用商店下载,安全放心。
本页为内部测试页。
@@ -82,16 +82,18 @@
1点右上角的 ··· 菜单
2选择「在浏览器打开」
-
3在浏览器里点「下载安装包」
+
3在浏览器里按提示去应用商店下载
我知道了 ✕
diff --git a/docs/api/README.md b/docs/api/README.md index 6556d39..7ff8981 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -102,6 +102,7 @@ | A24 | `DELETE /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) | | A25 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin-marquee-seeds.md) | | A26 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./admin-marquee-seeds.md) | +| A27 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin-ad-coin-audit.md)(看广告金币公式复算对账,只读) | | - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) | > ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。 diff --git a/docs/api/ad-ecpm-report.md b/docs/api/ad-ecpm-report.md index 40782b7..b29fefb 100644 --- a/docs/api/ad-ecpm-report.md +++ b/docs/api/ad-ecpm-report.md @@ -9,7 +9,7 @@ |---|---|---|---| | `ad_type` | str | 是 | 广告类型:`reward_video`(激励视频) / `draw`(Draw 信息流) 等 | | `ad_session_id` | str\|null | 否 | 客户端生成的广告会话 ID;需和穿山甲 `extra.ad_session_id` 一致,用于 S2S 缺 eCPM 时匹配 | -| `ecpm` | str | 是 | 穿山甲 `getShowEcpm().getEcpm()` 原始字符串,后端按“元/千次展示”参与金币公式 | +| `ecpm` | str | 是 | 穿山甲 `getShowEcpm().getEcpm()` 原始字符串,单位是**分/千次展示**(非元),后端 ÷100 转元参与金币公式 | | `adn` | str\|null | 否 | 实际投放 ADN(`getSdkName`),如 `pangle` | | `slot_id` | str\|null | 否 | 实际展示代码位(底层 mediation rit,非客户端配置位) | diff --git a/docs/api/ad-feed-reward.md b/docs/api/ad-feed-reward.md index be46efa..0510a0f 100644 --- a/docs/api/ad-feed-reward.md +++ b/docs/api/ad-feed-reward.md @@ -12,7 +12,7 @@ |---|---|---:|---| | `client_event_id` | string | 是 | 客户端生成的幂等事件 id,8-64 字符 | | `ad_session_id` | string\|null | 否 | 客户端生成的一次信息流广告会话 id,用于对账/排查 | -| `ecpm` | string | 是 | 本条信息流广告 eCPM,按“元/千次展示”处理 | +| `ecpm` | string | 是 | 本条信息流广告 eCPM(穿山甲 getEcpm 原值),按“分/千次展示”处理(非元) | | `duration_seconds` | int | 是 | 实际展示/播放秒数 | | `adn` | string\|null | 否 | 实际投放 ADN | | `slot_id` | string\|null | 否 | 实际展示代码位 | diff --git a/docs/api/ad-test-grant.md b/docs/api/ad-test-grant.md index 9c439cb..c4a0638 100644 --- a/docs/api/ad-test-grant.md +++ b/docs/api/ad-test-grant.md @@ -10,6 +10,7 @@ | 字段 | 类型 | 必填 | 默认 | 说明 | |---|---|---|---|---| | `reward_scene` | string | 否 | `reward_video` | 模拟发奖场景。`reward_video`=普通激励视频;`signin_boost`=签到膨胀 | +| `ad_session_id` | string(8~64) \| null | 否 | null | 本次广告会话 id(与 [ecpm-report](./ad-ecpm-report.md) 同值)。**仅 `reward_video` 场景生效**:据此查回客户端已上报的真实 eCPM,走与正式发奖相同的公式发奖;查不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍出非零金币 | ## 出参 响应 `200`:`TestGrantOut` @@ -30,4 +31,6 @@ ## 说明 没公网、穿山甲 S2S 回调打不到本地时,debug 客户端看完广告后调它,直接走与 [ad-pangle-callback](./ad-pangle-callback.md) 相同的发奖逻辑(每次新 `trans_id`,幂等 + 每日上限/今日膨胀一次)。 +`reward_scene=reward_video` 时按上面 `ad_session_id` 查回的真实 eCPM 走金币公式发奖(取不到兜底 200)——便于本地用 [admin 金币审计](./admin-ad-coin-audit.md) 核对「看广告→金币」是否按公式计算。 + `reward_scene=signin_boost` 时复用签到膨胀业务规则:必须当天已签到、非第 14 天、当天未膨胀过,成功后写入 `signin_boost` 金币流水。它让已登录客户端能自助发奖 = 绕过反作弊,**严禁在生产开启**。 diff --git a/docs/api/admin-ad-coin-audit.md b/docs/api/admin-ad-coin-audit.md new file mode 100644 index 0000000..8b69e71 --- /dev/null +++ b/docs/api/admin-ad-coin-audit.md @@ -0,0 +1,69 @@ +# Admin 看广告金币审计 + +> 所属:Admin 组(前缀 `/admin/api/ad-coin-audit`) | 鉴权:Admin Bearer(任意已登录 admin,只读) | [← 返回 API 索引](./README.md) + +把「看视频赚金币」(`ad_reward_record`)和「比价信息流广告」(`ad_feed_reward_record`)两类发奖记录,用与**正式发奖完全相同**的公式 [`app/core/rewards.py` `calculate_ad_reward_coin`](../../app/core/rewards.py) 复算一遍 `expected_coin`,与实际入账的 `actual_coin` 对比,核对金币公式是否生效。**纯只读对账**,不发币、不改任何数据。 + +相关表:[ad_reward_record](../database/ad_reward_record.md)、[ad_feed_reward_record](../database/ad_feed_reward_record.md)。 + +## 金币公式(展示参照) + +``` +eCPM元 = getEcpm分 ÷ 100 +单份金币 = round( eCPM元 ÷ 1000 × 因子1 × 因子2 × coin_per_yuan ) # coin_per_yuan=10000 +``` +- **eCPM 口径**:`getEcpm()` 原值单位是分/千次展示;先 ÷100 转元,**因子判档与收益换算都用元**。 +- **因子1(eCPM 档,阈值单位=元/千次)**:≤100→0.1,101–200→0.3,201–400→0.4,>400→0.6(即 ¥100/¥200/¥400 CPM 分界。**真实 eCPM 多 <¥100 CPM,故常落最低档 0.1,高档基本不触发——产品有意取舍**)。 +- **因子2(LT,当日第 N 份)**:第1→2.0,第2→1.5,第3→1.3,第4–10→1.1,≥11→1.0。 +- **第 N 份的计数**:看视频每条 granted 记 1 份;信息流每满 10 秒记 1 份。**两个点位各自独立计数**(看视频的份数不影响信息流的份数,反之亦然)。 + +## GET /admin/api/ad-coin-audit — 复算对比 + +- 入参(均 query,可选): + | 参数 | 类型 | 默认 | 说明 | + |---|---|---|---| + | `date` | string | 今天 | 北京时间 `YYYY-MM-DD`,审计某天 | + | `user_id` | int | 全部 | 只看某用户;不传=所有用户 | + | `scene` | string | 两类 | `reward_video` / `feed`;不传=两类都返回 | + | `limit` | int(1~500) | 100 | 返回明细条数(按时间倒序截断;**份序号在截断前已按全天数据算好**,不影响复算正确性) | + +- 出参 `200`:`AdCoinAuditOut` + | 字段 | 类型 | 说明 | + |---|---|---| + | `date` | string | 审计日期 | + | `formula` | object | 当前公式参数快照(见下) | + | `total` | int | 返回明细条数 | + | `mismatch_count` | int | 其中 `matched=false` 的条数;**=0 说明全部按公式发放** | + | `items` | `AdCoinAuditRow[]` | 明细(见下) | + +### AdCoinFormulaOut(`formula`) +| 字段 | 类型 | 说明 | +|---|---|---| +| `description` | string | 公式文字说明 | +| `coin_per_yuan` | int | 金币:元 汇率(10000) | +| `ecpm_unit` | string | eCPM 口径(分/千次展示,SDK getEcpm 原值) | +| `feed_unit_seconds` | int | 信息流每多少秒折 1 份(10) | +| `ecpm_factor_tiers` | `[因子, 下限, 上限\|null][]` | 因子1 档位表(直接读发奖常量,与发奖同源) | +| `lt_factor_tiers` | `[因子, 下限, 上限\|null][]` | 因子2 LT 档位表 | + +### AdCoinAuditRow(`items[]`) +| 字段 | 类型 | 说明 | +|---|---|---| +| `scene` | string | `reward_video` / `feed` | +| `record_id` | int | 对应记录表主键 | +| `user_id` | int | | +| `created_at` | datetime | | +| `status` | string | `granted` / `capped`(超每日上限未发) / `ecpm_missing`(缺 eCPM 未发) | +| `ecpm` | string \| null | 本次采用的 eCPM 原始值 | +| `ecpm_factor` | float \| null | 因子1;非 granted 为 null | +| `units` | int | 折算份数:看视频恒 1;信息流 = 满 10 秒份数 | +| `lt_index_start` / `lt_index_end` | int \| null | 本条占用「当日第几份」的起止(看视频起=止;信息流一条可跨多份) | +| `lt_factor_start` / `lt_factor_end` | float \| null | 因子2 的起止值(信息流跨份时会衰减,故给区间) | +| `expected_coin` | int | 按公式复算应发金币(非 granted 恒 0) | +| `actual_coin` | int | 实际入账金币 | +| `matched` | bool | 复算与实发是否一致;非 granted 校验实发是否确为 0 | + +## 说明 +- **非 granted 行**(capped/ecpm_missing)不占用份序号、应发恒 0,`matched` 用于校验「该不发的确实没发」。 +- 用法建议:**按 `user_id`+`date` 定位某次具体核对**最直观;不带 `user_id` 是全用户当天概览。 +- 本地联调造数:debug 包看完激励视频会调 [`/api/v1/ad/test-grant`](./ad-test-grant.md),它会用客户端按 `ad_session_id` 上报的真实 eCPM 发奖(取不到兜底 200),所以本审计能看到真实 eCPM 对应的金币。 diff --git a/docs/database/OVERVIEW.md b/docs/database/OVERVIEW.md index e5d2b6f..5e298d4 100644 --- a/docs/database/OVERVIEW.md +++ b/docs/database/OVERVIEW.md @@ -2,7 +2,7 @@ > 跨表视角。单表字段级细节看同目录 `<表名>.md`(索引见 [README](./README.md))。 > 本文专门回答三件「跨表」的事:**① 每块 App 功能用到哪些表 ② 什么操作往哪张表写 ③ 表和表怎么连(join key,含没有外键约束、靠业务字段对齐的语义关联)**。 -> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **22 张业务表** + `alembic_version`(框架的迁移版本指针)。 +> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **23 张业务表** + `alembic_version`(框架的迁移版本指针)。 --- @@ -35,6 +35,7 @@ | App 位置 / 动作 | 表 | 说明 | |---|---|---| | 登录(极光/短信)/ 改资料 / 注销 | [`user`](./user.md) | 登录主体,注册即登录 | +| 新手引导是否再展示 | [`onboarding_completion`](./onboarding_completion.md) | 按 设备+账号 去重;登录响应回 `onboarding_completed`,走完引导时标记,跨卸载重装 | | 帮助与反馈 | [`feedback`](./feedback.md) | 含截图,后台人工处理 | ### 运营后台 admin(独立子应用 `app/admin/`,端口 8771,独立鉴权) @@ -54,7 +55,8 @@ ### C 端(App 用户触发) | 触发(用户动作 / endpoint / 回调) | 写入 | 操作 | |---|---|---| -| 登录 `POST /auth/jverify-login`、`/auth/sms/login` | `user` | C(首次=注册)/ U(`last_login_at`) | +| 登录 `POST /auth/jverify-login`、`/auth/sms/login` | `user` | C(首次=注册)/ U(`last_login_at`);并**读** `onboarding_completion` 回 `onboarding_completed` | +| 走完新手引导 `POST /user/onboarding/complete` | `onboarding_completion`(C) | `(user_id, device_id)` 幂等,撞唯一约束即忽略 | | 改昵称 `PATCH /user/profile`、传头像 `POST /user/avatar` | `user` | U | | 注销 `DELETE /user` | `user` | U(软删:`phone→deleted_`、`status=deleted`) | | 绑/解绑微信 `POST /wallet/bind-wechat`、`/unbind-wechat` | `user`.wechat_* | U | @@ -120,6 +122,7 @@ - **`comparison_record.store_name` ≈ `savings_record.shop_name`**:无 id 关联,按**店名字符串相等**给比价记录打「已下单」标记(瞬态,不写库)。两边店名同源 = 比价意图识别阶段的门店 query,语义=**店级**(同店比价多次会一并标已下单)。 - **广告流会话关联**:`ad_reward_record.ad_session_id` 可与 `ad_ecpm_record.ad_session_id` 对齐;`ad_watch_log` 仍是旧版兼容统计,不逐条参与发奖。 - **里程碑解锁进度不存库**:`comparison_milestone_claim` 只记「哪几档已领」;进度 = `comparison_record` 里 `status='success'` 的 `count`。 +- **`onboarding_completion.(user_id, device_id)`**:`user_id` 语义关联 `user.id`(无硬 FK,同 `coupon_*` 设备表),`device_id` = 客户端硬件级 `ANDROID_ID`(≠ 领券 per-install `device_id`)。登录读、走完引导写,决定是否再展示新手引导。 ### ER 关系(文字版) ``` @@ -129,6 +132,7 @@ user ─1:N─ { coin_transaction, cash_transaction, withdraw_order, signin_reco signin_boost_record, user_task, comparison_record, comparison_milestone_claim, savings_record, ad_reward_record, ad_watch_log, ad_ecpm_record, ad_feed_reward_record, price_report, feedback } +user ─1:N─ onboarding_completion (user_id, 无硬 FK; (user_id,device_id) 去重) comparison_record ─1:N─ price_report (comparison_record_id, 可空) admin_user ─1:N─ admin_audit_log app_config (独立, 无外键, key 为主键) diff --git a/docs/database/README.md b/docs/database/README.md index 9220687..abffd58 100644 --- a/docs/database/README.md +++ b/docs/database/README.md @@ -3,18 +3,19 @@ > 数据库:SQLite 起步(`data/app.db`),生产可切 PostgreSQL(改 `DATABASE_URL`)。 > ORM:SQLAlchemy 2.0(`app/models/`),迁移:Alembic(`alembic/versions/`,`render_as_batch` 兼容 SQLite)。 > 金额字段一律存**整数**:金币=个数,现金=**分**(`*_cents`)。时间列 `DateTime(timezone=True)`。 -> 最后更新:2026-06-07(补全 22 张业务表 + 新增 [OVERVIEW 总览](./OVERVIEW.md)) +> 最后更新:2026-06-10(新增 `onboarding_completion` 新手引导完成表 → 23 张业务表) > 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。 --- -## 表总览(22 张业务表 + `alembic_version` 框架表) +## 表总览(23 张业务表 + `alembic_version` 框架表) ### 账号 / 反馈 | 表 | 用途 | 模型 | 文档 | |---|---|---|---| | `user` | 用户(登录主体);几乎所有表的外键宿主 | `models/user.py` | [详情](./user.md) | +| `onboarding_completion` | 新手引导完成标记(按 设备+账号 去重,登录时据此跳过引导) | `models/onboarding.py` | [详情](./onboarding_completion.md) | | `feedback` | 用户帮助与反馈(含截图) | `models/feedback.py` | [详情](./feedback.md) | ### 钱包 / 福利(看广告赚钱闭环) diff --git a/docs/database/ad_ecpm_record.md b/docs/database/ad_ecpm_record.md index 687f5fc..127636f 100644 --- a/docs/database/ad_ecpm_record.md +++ b/docs/database/ad_ecpm_record.md @@ -18,7 +18,7 @@ | `ad_session_id` | String(64) | UNIQUE, index, nullable | 客户端广告会话 ID;用于普通激励视频在 S2S 缺 `ecpm` 时匹配发奖 | | `adn` | String(32) | nullable | 实际投放 ADN(`getShowEcpm().getSdkName()`,如 `pangle`/`gdt`) | | `slot_id` | String(64) | nullable | 实际展示用代码位(底层 mediation rit,非客户端配置位) | -| `ecpm_raw` | String(32) | NOT NULL | 客户端上报的 eCPM **原始串**;后端按“元/千次展示”参与金币公式 | +| `ecpm_raw` | String(32) | NOT NULL | 客户端上报的 eCPM **原始串**(穿山甲 getEcpm 原值,单位**分/千次展示**);后端 ÷100 转元参与金币公式 | | `report_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它做按天聚合 | | `created_at` | DateTime(tz) | server_default now(), index | 时间 | diff --git a/docs/database/onboarding_completion.md b/docs/database/onboarding_completion.md new file mode 100644 index 0000000..8e450c1 --- /dev/null +++ b/docs/database/onboarding_completion.md @@ -0,0 +1,44 @@ +# onboarding_completion — 新手引导完成标记(按 设备 + 账号 去重) + +> 模型 `app/models/onboarding.py` · 仓库 `app/repositories/onboarding.py` · 迁移 `alembic/versions/onboarding_completion_table.py`(revision `onboarding_completion`) · 读 `POST /api/v1/auth/jverify-login`·`/sms/login` · 写 `POST /api/v1/user/onboarding/complete` · 测试 `tests/test_onboarding.py` · [← 索引](./README.md) · [总览](./OVERVIEW.md) + +记录「**某账号在某台设备上已走完新手引导**」,一条 `(user_id, device_id)`。用途单一:决定**登录后要不要再展示新手引导教程**——本表有这条 → 跳过引导直接进首页;没有 → 走引导。 + +**为什么要落后端**:此前「引导只跑一次」只存客户端本地 `OnboardingPrefs`(SharedPreferences),**卸载重装即丢** → 重装会重弹引导。产品需求是「同设备 + 同账号只触发一次,且跨卸载重装」,本地存不住,故把"真相"放到服务端,按 **账号 + 硬件设备号** 去重。 + +**去重维度 = 设备 + 账号**(产品决议): + +| 场景 | 命中本表? | 是否再展示引导 | +|---|---|---| +| 新账号(从未走过) | 否 | ✅ 展示 | +| 同账号 + **同设备**(卸载重装 / 退登重登) | 是 | ❌ 跳过 | +| 同账号 + **换新设备** | 否 | ✅ 展示(新机需重新授权悬浮窗/无障碍等) | + +> ⚠️ `device_id` 用客户端**硬件级稳定标识** `Settings.Secure.ANDROID_ID`(`util/DeviceId.hardwareId()`,同签名 app 卸载重装不变、仅恢复出厂重置),**不是**领券/比价用的 per-install `DeviceId.get()`(存 SP、重装会变)。两套 device_id 不互通,别混用。 + +## 用在哪 / 增删改查 +- **R(读)**:登录 `POST /auth/jverify-login`、`/auth/sms/login` —— 客户端在登录请求体里带 `device_id`,`onboarding_repo.is_completed(user_id, device_id)` 查本表有无行,结果塞进登录响应 `TokenWithUser.onboarding_completed`(true=已完成)。客户端 `AuthRepository.persist` 据此回写本地 `OnboardingPrefs`(只回写 true),既有 gating(`OnboardingHost` / `AppNavHost`)逻辑不变。 +- **C(插入)**:走完引导最后一步(后台耗电屏)时,客户端 `POST /api/v1/user/onboarding/complete`(带 Bearer + `device_id`)→ `onboarding_repo.mark_completed` 插一行。**幂等**:同 `(user_id, device_id)` 已存在则撞唯一约束,`IntegrityError` 被 rollback 吞掉、视为成功;`device_id` 为空一律忽略不写。best-effort:客户端上报失败不阻断进首页(本地已先标记)。 +- **U / D**:无。一台设备 + 一账号一条,完成即永久;账号注销(软删 `user`)**不清本表**(留着无害——user_id 指向的是已 `deleted` 的行;同手机号重新注册是新 user.id,自然重新走引导)。 + +## 字段 +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | +|---|---|---|---| +| `id` | Integer | PK, autoincrement | | +| `user_id` | Integer | index, NOT NULL | 归属账号 → `user.id`(语义外键,未建 DB 级 FK,同 `coupon_*` 设备表) | +| `device_id` | String(64) | NOT NULL | 硬件级设备标识 = 客户端 `Settings.Secure.ANDROID_ID`;卸载重装不变 | +| `completed_at` | DateTime(tz) | server_default now() | 标记完成的时间 | + +## 关系 / Join Key +- `user_id` → `user.id`(多对一:一账号在 N 台设备各一条)。**无硬 FK 约束**(同 `coupon_claim_record` / `coupon_prompt_engagement`),靠业务字段对齐。 +- 判断维度 = `(user_id, device_id)` 组合等值查。 +- `device_id` 与客户端 `DeviceId.hardwareId()`(ANDROID_ID)同源;**不**与领券侧 per-install `device_id` 互通。 + +## 索引与约束 +- PK `id`;index `user_id`(`ix_onboarding_completion_user_id`);UNIQUE(`user_id`, `device_id`) = `uq_onboarding_user_device`(防同账号同设备重复写 + 兜并发 upsert)。 + +## 注意 +- **去重维度是「设备 + 账号」**,不是纯账号:同一个人在新手机上登录会重新看引导(产品预期:新机要重新授权权限)。若日后想改「纯账号、换机也不弹」,把 `is_completed`/`mark_completed` 去掉 `device_id` 维度即可。 +- `device_id` 取不到 / 为已知脏值(`9774d56d682e549c`)时客户端退回 per-install id,这类极少数设备重装会重弹一次(尽力而为)。 +- 本地 `OnboardingPrefs` 现仅作**同安装快速跳过缓存**;真相以本表为准,每次登录响应同步。 +- 客户端实现:`ui/onboarding/OnboardingViewModel.kt`(上报)、`data/auth/AuthRepository.kt`(读 + 同步本地)、`util/DeviceId.kt`(ANDROID_ID)。 diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..1a83f75 --- /dev/null +++ b/run.bat @@ -0,0 +1,35 @@ +@echo off +REM Local dev startup script (Windows) - mirror of run.sh +REM +REM Usage: +REM cd shaguabijia-app-server +REM run.bat +REM +REM Listens on 0.0.0.0:8770 (works for both real device via adb reverse +REM and LAN). Auto-reload on code change. +REM +REM Prerequisite (first time): +REM conda activate pricebot ^&^& pip install -e . +REM copy .env.example .env ^&^& fill JWT_SECRET_KEY +REM +REM This file is the Windows-native peer of run.sh. Keeping run.sh untouched +REM avoids CRLF/encoding fights every time someone bash-runs the .sh on Win. + +cd /d "%~dp0" + +if not exist .env ( + echo [X] Missing .env. Run: copy .env.example .env and fill JWT_SECRET_KEY ^(plus MT_CPS_* if you test Meituan^) + exit /b 1 +) + +if not exist data mkdir data + +REM Build/upgrade SQLite schema (idempotent; no-op if already at head) +call alembic upgrade head +if errorlevel 1 ( + echo [X] alembic upgrade head failed + exit /b %errorlevel% +) + +REM Long-running foreground process. Ctrl+C to stop. +uvicorn app.main:app --host 0.0.0.0 --port 8770 --reload diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py new file mode 100644 index 0000000..3b6f533 --- /dev/null +++ b/tests/test_onboarding.py @@ -0,0 +1,67 @@ +"""新手引导"按 设备+账号 只触发一次"的后端闭环测试。 + +覆盖: +- 全新 (账号, 设备) 登录 → onboarding_completed=False(照常走引导) +- 标记完成后,同 (账号, 设备) 再登录 → True(跳过引导,跨"重装"= 同 device_id 重新登录) +- 同账号换设备(device_id 不同)→ False(新设备重新引导) +- 标记幂等;缺 device_id 时按未完成处理 +""" +from __future__ import annotations + + +def _login(client, phone: str, device_id: str | None): + body = {"phone": phone, "code": "123456"} + if device_id is not None: + body["device_id"] = device_id + r = client.post("/api/v1/auth/sms/login", json=body) + assert r.status_code == 200, r.text + return r.json() + + +def _mark_complete(client, access: str, device_id: str): + return client.post( + "/api/v1/user/onboarding/complete", + json={"device_id": device_id}, + headers={"Authorization": f"Bearer {access}"}, + ) + + +def test_onboarding_once_per_device_and_account(client) -> None: + phone = "13800138001" + dev_a = "android-id-aaaa" + dev_b = "android-id-bbbb" + + # ① 全新账号+设备:未完成 + data = _login(client, phone, dev_a) + assert data["onboarding_completed"] is False + access = data["access_token"] + + # ② 走完引导 → 标记完成(幂等:再标一次也 200) + assert _mark_complete(client, access, dev_a).status_code == 200 + assert _mark_complete(client, access, dev_a).json()["ok"] is True + + # ③ 同账号 + 同设备(模拟卸载重装后重新登录)→ 已完成,跳过引导 + assert _login(client, phone, dev_a)["onboarding_completed"] is True + + # ④ 同账号 + 新设备 → 仍未完成,新设备重新走引导 + assert _login(client, phone, dev_b)["onboarding_completed"] is False + + +def test_onboarding_mark_on_one_device_does_not_leak_to_other(client) -> None: + """B 设备标记完成,不影响 A 设备(逐设备独立)。""" + phone = "13800138002" + data_a = _login(client, phone, "devA") + assert data_a["onboarding_completed"] is False + + data_b = _login(client, phone, "devB") + assert _mark_complete(client, data_b["access_token"], "devB").status_code == 200 + + assert _login(client, phone, "devB")["onboarding_completed"] is True + assert _login(client, phone, "devA")["onboarding_completed"] is False + + +def test_onboarding_missing_device_id_treated_as_incomplete(client) -> None: + """老客户端不传 device_id:按未完成处理,不报错、不误跳过。""" + phone = "13800138003" + data = _login(client, phone, device_id=None) + assert data["onboarding_completed"] is False