diff --git a/app/admin/repositories/stats.py b/app/admin/repositories/stats.py index abc3980..66baead 100644 --- a/app/admin/repositories/stats.py +++ b/app/admin/repositories/stats.py @@ -5,7 +5,8 @@ user.last_login_at / comparison_record.status / withdraw_order.status)要加索 """ from __future__ import annotations -from datetime import datetime, timedelta, timezone +from datetime import date, datetime, time, timedelta, timezone +from decimal import Decimal, InvalidOperation from sqlalchemy import func, select from sqlalchemy.orm import Session @@ -13,12 +14,27 @@ from sqlalchemy.orm import Session from app.models.ad_feed_reward import AdFeedRewardRecord from app.models.ad_reward import AdRewardRecord from app.models.comparison import ComparisonRecord +from app.models.coupon_state import CouponPromptEngagement +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.user import User from app.models.wallet import CoinTransaction, WithdrawOrder _BEIJING = timezone(timedelta(hours=8)) +COUPON_REWARD_BIZ_TYPES = ("reward_video", "ad_reward", "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 = ( + *COUPON_REWARD_BIZ_TYPES, + *COMPARISON_REWARD_BIZ_TYPES, + *EXCLUDED_REWARD_BIZ_TYPES, + *UNCLASSIFIED_FEED_BIZ_TYPES, +) +MEITUAN_CPS_INVALID_STATUSES = ("4", "5") +MEITUAN_CPS_SETTLED_STATUS = "6" def _beijing_today_start_utc() -> datetime: @@ -31,30 +47,87 @@ def _beijing_today_start_utc() -> datetime: def today_dau(db: Session) -> int: """今日活跃用户数(DAU):北京时区今天 0 点后登录过(last_login_at)。 - 大盘与广告收益报表共用此口径,单一来源避免漂移。 - ⚠️ last_login_at 是单值字段(只存最后一次登录时刻),故只能算「今日」, - 无法回溯历史某天的 DAU——调用方按此约束决定历史区间是否展示。 + 广告收益报表复用这个函数;历史窗口 DAU 由 dashboard_overview 的 period 口径另算。 """ today_start = _beijing_today_start_utc() - return db.execute( - select(func.count(User.id)).where(User.last_login_at >= today_start) - ).scalar_one() + return int( + db.execute(select(func.count(User.id)).where(User.last_login_at >= today_start)).scalar_one() + ) -def dashboard_overview(db: Session) -> dict: +def _default_period_end() -> date: + """新版大盘不含今日,默认窗口结束日=北京时间昨天。""" + return datetime.now(_BEIJING).date() - timedelta(days=1) + + +def _normalize_period(date_from: date | None, date_to: date | None) -> tuple[date, date]: + end = date_to or _default_period_end() + start = date_from or end + if start > end: + start, end = end, start + return start, end + + +def _period_bounds(date_from: date, date_to: date) -> tuple[datetime, datetime, datetime, datetime]: + """返回同一北京自然日窗口的 UTC aware 边界和北京 naive 边界。 + + user.created_at / last_login_at 是 UTC aware 口径;比较/金币等历史上有北京 naive + 写入,所以两套边界同时保留。 + """ + 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) + return ( + start_utc, + end_utc, + start_bj.replace(tzinfo=None), + end_bj.replace(tzinfo=None), + ) + + +def _date_range(date_from: date, date_to: date) -> list[date]: + days = (date_to - date_from).days + return [date_from + timedelta(days=i) for i in range(days + 1)] + + +def _commission_rate_percent(raw: str | None) -> Decimal | None: + """美团 commissionRate 原值: "300"=3%, "10"=0.1%;也兼容 "3%"。""" + if raw is None: + return None + s = str(raw).strip() + if not s: + return None + try: + if s.endswith("%"): + return Decimal(s[:-1]) + val = Decimal(s) + except (InvalidOperation, ValueError): + return None + return val / Decimal("100") + + +def dashboard_overview( + db: Session, *, date_from: date | None = None, date_to: date | None = None +) -> dict: today_start = _beijing_today_start_utc() + period_from, period_to = _normalize_period(date_from, date_to) + start_utc, end_utc, start_local, end_local = _period_bounds(period_from, period_to) def _count(model, *conds) -> int: stmt = select(func.count(model.id)) if conds: stmt = stmt.where(*conds) - return db.execute(stmt).scalar_one() + return int(db.execute(stmt).scalar_one()) def _sum(col, *conds) -> int: stmt = select(func.coalesce(func.sum(col), 0)) if conds: stmt = stmt.where(*conds) - return db.execute(stmt).scalar_one() + return int(db.execute(stmt).scalar_one()) + + def _user_id_set(stmt) -> set[int]: + return {int(v) for v in db.execute(stmt).scalars().all() if v is not None} # ===== 用户 ===== by_status = dict( @@ -74,6 +147,204 @@ def dashboard_overview(db: Session) -> dict: comparison_total = _count(ComparisonRecord) comparison_success = _count(ComparisonRecord, ComparisonRecord.status == "success") success_rate = round(comparison_success / comparison_total, 4) if comparison_total else 0.0 + period_comparison_conds = ( + 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_success_rate = ( + round(period_comparison_success / period_comparison_total, 4) + if period_comparison_total + else 0.0 + ) + period_saved_positive_count = _count( + ComparisonRecord, + *period_comparison_conds, + ComparisonRecord.status == "success", + ComparisonRecord.saved_amount_cents > 0, + ) + period_saved_positive_sum = _sum( + ComparisonRecord.saved_amount_cents, + *period_comparison_conds, + ComparisonRecord.status == "success", + ComparisonRecord.saved_amount_cents > 0, + ) + period_avg_saved_cents = ( + round(period_saved_positive_sum / period_saved_positive_count) + if period_saved_positive_count + else None + ) + period_avg_duration_ms = db.execute( + select(func.avg(ComparisonRecord.total_ms)).where( + *period_comparison_conds, + ComparisonRecord.total_ms.is_not(None), + ComparisonRecord.total_ms > 0, + ) + ).scalar_one() + period_avg_duration_ms = ( + round(float(period_avg_duration_ms)) + if period_avg_duration_ms is not None + else None + ) + + ordered_exists = ( + select(SavingsRecord.id) + .where( + SavingsRecord.user_id == ComparisonRecord.user_id, + SavingsRecord.source == "compare", + SavingsRecord.shop_name.is_not(None), + SavingsRecord.shop_name == ComparisonRecord.store_name, + ) + .exists() + ) + period_ordered_count = _count( + ComparisonRecord, + *period_comparison_conds, + ComparisonRecord.store_name.is_not(None), + ordered_exists, + ) + + # ===== 日期窗口用户 ===== + period_new_user_ids = _user_id_set( + select(User.id).where(User.created_at >= start_utc, User.created_at < end_utc) + ) + login_user_ids = _user_id_set( + select(User.id).where(User.last_login_at >= start_utc, User.last_login_at < end_utc) + ) + compare_user_ids = _user_id_set( + select(ComparisonRecord.user_id).where(*period_comparison_conds) + ) + coupon_user_ids = _user_id_set( + select(CouponPromptEngagement.user_id).where( + CouponPromptEngagement.engage_date >= period_from, + CouponPromptEngagement.engage_date <= period_to, + CouponPromptEngagement.engage_type == "claim_started", + ) + ) + period_active_user_ids = login_user_ids | compare_user_ids | coupon_user_ids + period_retained_new_user_ids = period_new_user_ids & period_active_user_ids + period_retention_rate = ( + round(len(period_retained_new_user_ids) / len(period_new_user_ids), 4) + if period_new_user_ids + else None + ) + trend_points: list[dict] = [] + for cur_date in _date_range(period_from, period_to): + day_start_utc, day_end_utc, day_start_local, day_end_local = _period_bounds( + cur_date, cur_date + ) + daily_comparison_conds = ( + ComparisonRecord.created_at >= day_start_local, + ComparisonRecord.created_at < day_end_local, + ) + daily_login_user_ids = _user_id_set( + select(User.id).where( + User.last_login_at >= day_start_utc, + User.last_login_at < day_end_utc, + ) + ) + daily_compare_user_ids = _user_id_set( + select(ComparisonRecord.user_id).where(*daily_comparison_conds) + ) + daily_coupon_user_ids = _user_id_set( + select(CouponPromptEngagement.user_id).where( + CouponPromptEngagement.engage_date == cur_date, + CouponPromptEngagement.engage_type == "claim_started", + ) + ) + trend_points.append( + { + "date": cur_date, + "active_users": len( + daily_login_user_ids | daily_compare_user_ids | daily_coupon_user_ids + ), + "new_users": _count( + User, + User.created_at >= day_start_utc, + User.created_at < day_end_utc, + ), + "comparisons": _count(ComparisonRecord, *daily_comparison_conds), + } + ) + + period_coin_conds = ( + CoinTransaction.created_at >= start_local, + CoinTransaction.created_at < end_local, + CoinTransaction.amount > 0, + ) + period_reward_video_coin_total = _sum( + CoinTransaction.amount, + *period_coin_conds, + CoinTransaction.biz_type.in_(("reward_video", "ad_reward")), + ) + period_feed_ad_coin_total = _sum( + CoinTransaction.amount, + *period_coin_conds, + CoinTransaction.biz_type == "feed_ad_reward", + ) + period_signin_coin_total = _sum( + CoinTransaction.amount, + *period_coin_conds, + CoinTransaction.biz_type == "signin", + ) + period_signin_boost_coin_total = _sum( + CoinTransaction.amount, + *period_coin_conds, + CoinTransaction.biz_type == "signin_boost", + ) + period_task_coin_total = _sum( + CoinTransaction.amount, + *period_coin_conds, + CoinTransaction.biz_type.like("task_%"), + ) + period_coupon_reward_coin_total = _sum( + CoinTransaction.amount, + *period_coin_conds, + CoinTransaction.biz_type.in_(COUPON_REWARD_BIZ_TYPES), + ) + period_comparison_reward_coin_total = _sum( + CoinTransaction.amount, + *period_coin_conds, + CoinTransaction.biz_type.in_(COMPARISON_REWARD_BIZ_TYPES), + ) + period_regular_task_coin_total = _sum( + CoinTransaction.amount, + *period_coin_conds, + CoinTransaction.biz_type.notin_(REGULAR_TASK_EXCLUDED_BIZ_TYPES), + ) + period_meituan_orders = list( + db.execute( + select(CpsOrder).where( + CpsOrder.pay_time >= start_utc, + CpsOrder.pay_time < end_utc, + ) + ).scalars() + ) + period_meituan_valid_orders = [ + o for o in period_meituan_orders if o.mt_status not in MEITUAN_CPS_INVALID_STATUSES + ] + period_meituan_hit_count = 0 + period_meituan_miss_count = 0 + period_meituan_unknown_rate_count = 0 + for order in period_meituan_valid_orders: + rate = _commission_rate_percent(order.commission_rate) + if rate is None: + period_meituan_unknown_rate_count += 1 + elif rate < Decimal("1"): + period_meituan_miss_count += 1 + else: + period_meituan_hit_count += 1 + period_meituan_hit_denominator = period_meituan_hit_count + period_meituan_miss_count + period_meituan_hit_rate = ( + round(period_meituan_hit_count / period_meituan_hit_denominator, 4) + if period_meituan_hit_denominator + else None + ) return { "users": { @@ -132,7 +403,61 @@ def dashboard_overview(db: Session) -> dict: "success": comparison_success, "success_rate": success_rate, }, - "feedback": {"new": _count(Feedback, Feedback.status.in_(("pending", "new")))}, - # CPS 收入数据源未接(referral-link 只换链接,转化/佣金未回收)→ 前端显示"待接入"。 - "cps": {"available": False, "note": "CPS 转化数据未接入(P2)"}, + "period": { + "date_from": period_from, + "date_to": period_to, + "users": { + "new": len(period_new_user_ids), + "active": len(period_active_user_ids), + "retained_new_users": len(period_retained_new_user_ids), + "retention_rate": period_retention_rate, + "retention_note": ( + "近似口径:登录(last_login_at)+已上报比价记录+领券claim_started;" + "尚不包含未完成上报的比价开始事件" + ), + }, + "comparison": { + "total": period_comparison_total, + "success": period_comparison_success, + "success_rate": period_comparison_success_rate, + "ordered": period_ordered_count, + "average_duration_ms": period_avg_duration_ms, + "average_saved_cents": period_avg_saved_cents, + }, + "coins": { + "granted_total": _sum(CoinTransaction.amount, *period_coin_conds), + "reward_video_coin_total": period_reward_video_coin_total, + "feed_ad_coin_total": period_feed_ad_coin_total, + "signin_coin_total": period_signin_coin_total, + "signin_boost_coin_total": period_signin_boost_coin_total, + "task_coin_total": period_task_coin_total, + "coupon_reward_coin_total": period_coupon_reward_coin_total, + "comparison_reward_coin_total": period_comparison_reward_coin_total, + "regular_task_coin_total": period_regular_task_coin_total, + }, + "cash": { + "withdraw_success_cents": _sum( + WithdrawOrder.amount_cents, + WithdrawOrder.status == "success", + WithdrawOrder.created_at >= start_local, + WithdrawOrder.created_at < end_local, + ), + }, + "trend": trend_points, + }, + "feedback": { + "new": _count(Feedback, Feedback.status.in_(("pending", "new"))), + }, + "cps": { + "available": True, + "note": "美团 CPS 读 cps_order 对账订单;淘宝/京东佣金暂空", + "meituan_order_count": len(period_meituan_valid_orders), + "meituan_commission_cents": sum( + o.commission_cents or 0 for o in period_meituan_valid_orders + ), + "meituan_hit_count": period_meituan_hit_count, + "meituan_miss_count": period_meituan_miss_count, + "meituan_unknown_rate_count": period_meituan_unknown_rate_count, + "meituan_hit_rate": period_meituan_hit_rate, + }, } diff --git a/app/admin/routers/cps.py b/app/admin/routers/cps.py index aa69270..433f8f6 100644 --- a/app/admin/routers/cps.py +++ b/app/admin/routers/cps.py @@ -7,7 +7,7 @@ from __future__ import annotations import time -from datetime import datetime, timedelta, timezone +from datetime import date as _date, datetime, time as _dt_time, timedelta, timezone from typing import Annotated from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile @@ -340,24 +340,77 @@ def generate_referral_links( # ───────────── 订单对账 ───────────── +_BEIJING = timezone(timedelta(hours=8)) + + +def _parse_day(value: str | None, *, field: str) -> _date | None: + if value is None: + return None + try: + return _date.fromisoformat(value) + except ValueError as e: + raise HTTPException(status_code=422, detail=f"{field} 需为 YYYY-MM-DD") from e + + +def _reconcile_range_to_ts( + date_from: _date | None, date_to: _date | None, days: int +) -> tuple[int, int]: + if date_from is None and date_to is None: + now = int(time.time()) + return now - days * 86400, now + + start_day = date_from or date_to + end_day = date_to or date_from + if start_day is None or end_day is None: + raise HTTPException(status_code=422, detail="日期参数不完整") + if start_day > end_day: + start_day, end_day = end_day, start_day + if (end_day - start_day).days + 1 > 90: + raise HTTPException(status_code=422, detail="美团订单查询最长 90 天") + + start_dt = datetime.combine(start_day, _dt_time.min, tzinfo=_BEIJING) + end_dt = datetime.combine(end_day + timedelta(days=1), _dt_time.min, tzinfo=_BEIJING) + return int(start_dt.timestamp()), int(end_dt.timestamp()) + + @router.post("/orders/reconcile", response_model=CpsReconcileResult, summary="拉取美团订单对账") def reconcile_orders( request: Request, admin: Annotated[AdminUser, Depends(require_role("finance"))], db: AdminDb, - days: Annotated[int, Query(ge=1, le=90)] = 7, + date_from: Annotated[str | None, Query(description="起始日 YYYY-MM-DD")] = None, + date_to: Annotated[str | None, Query(description="结束日 YYYY-MM-DD")] = None, + days: Annotated[int, Query(ge=1, le=90, description="未传日期时默认拉近 N 天")] = 7, sid: Annotated[str | None, Query(max_length=64)] = None, + query_time_type: Annotated[int, Query(ge=1, le=2)] = 1, ) -> CpsReconcileResult: - now = int(time.time()) + start_ts, end_ts = _reconcile_range_to_ts( + _parse_day(date_from, field="date_from"), + _parse_day(date_to, field="date_to"), + days, + ) try: result = cps_repo.reconcile_orders( - db, start_time=now - days * 86400, end_time=now, sid=sid, + db, + start_time=start_ts, + end_time=end_ts, + query_time_type=query_time_type, + sid=sid, ) except MeituanCpsError as e: raise HTTPException(status_code=502, detail=f"美团拉单失败: {e}") from e write_audit( db, admin, action="cps.order.reconcile", target_type="cps_order", target_id=None, - detail={"days": days, "sid": sid, **result}, ip=get_client_ip(request), commit=True, + detail={ + "date_from": date_from, + "date_to": date_to, + "days": days, + "sid": sid, + "query_time_type": query_time_type, + **result, + }, + ip=get_client_ip(request), + commit=True, ) return CpsReconcileResult(**result) diff --git a/app/admin/routers/dashboard.py b/app/admin/routers/dashboard.py index 138c8d8..d8f1201 100644 --- a/app/admin/routers/dashboard.py +++ b/app/admin/routers/dashboard.py @@ -1,7 +1,9 @@ """admin 数据大盘(只读聚合)。""" from __future__ import annotations -from fastapi import APIRouter, Depends +from datetime import date + +from fastapi import APIRouter, Depends, Query from app.admin.deps import AdminDb, get_current_admin from app.admin.repositories import stats @@ -15,5 +17,11 @@ router = APIRouter( @router.get("/overview", response_model=DashboardOverview, summary="大盘核心指标") -def overview(db: AdminDb) -> DashboardOverview: - return DashboardOverview.model_validate(stats.dashboard_overview(db)) +def overview( + db: AdminDb, + date_from: date | None = Query(None, description="北京时间自然日起始日 YYYY-MM-DD"), + date_to: date | None = Query(None, description="北京时间自然日结束日 YYYY-MM-DD"), +) -> DashboardOverview: + return DashboardOverview.model_validate( + stats.dashboard_overview(db, date_from=date_from, date_to=date_to) + ) diff --git a/app/admin/schemas/dashboard.py b/app/admin/schemas/dashboard.py index 10d9dfa..0ec1afb 100644 --- a/app/admin/schemas/dashboard.py +++ b/app/admin/schemas/dashboard.py @@ -1,6 +1,8 @@ """admin 大盘 schemas(对应 stats.dashboard_overview 的嵌套结构)。""" from __future__ import annotations +from datetime import date + from pydantic import BaseModel @@ -38,6 +40,56 @@ class DashboardComparison(BaseModel): success_rate: float +class DashboardPeriodUsers(BaseModel): + new: int + active: int + retained_new_users: int + retention_rate: float | None = None + retention_note: str + + +class DashboardPeriodComparison(BaseModel): + total: int + success: int + success_rate: float + ordered: int + average_duration_ms: int | None = None + average_saved_cents: int | None = None + + +class DashboardPeriodCoins(BaseModel): + granted_total: int + reward_video_coin_total: int = 0 + feed_ad_coin_total: int = 0 + signin_coin_total: int = 0 + signin_boost_coin_total: int = 0 + task_coin_total: int = 0 + coupon_reward_coin_total: int = 0 + comparison_reward_coin_total: int = 0 + regular_task_coin_total: int = 0 + + +class DashboardPeriodCash(BaseModel): + withdraw_success_cents: int + + +class DashboardTrendPoint(BaseModel): + date: date + active_users: int + new_users: int + comparisons: int + + +class DashboardPeriod(BaseModel): + date_from: date + date_to: date + users: DashboardPeriodUsers + comparison: DashboardPeriodComparison + coins: DashboardPeriodCoins + cash: DashboardPeriodCash + trend: list[DashboardTrendPoint] = [] + + class DashboardFeedback(BaseModel): new: int @@ -45,6 +97,12 @@ class DashboardFeedback(BaseModel): class DashboardCps(BaseModel): available: bool note: str + meituan_order_count: int = 0 + meituan_commission_cents: int = 0 + meituan_hit_count: int = 0 + meituan_miss_count: int = 0 + meituan_unknown_rate_count: int = 0 + meituan_hit_rate: float | None = None class DashboardOverview(BaseModel): @@ -52,5 +110,6 @@ class DashboardOverview(BaseModel): coins: DashboardCoins cash: DashboardCash comparison: DashboardComparison + period: DashboardPeriod feedback: DashboardFeedback cps: DashboardCps diff --git a/app/models/__init__.py b/app/models/__init__.py index 35662b3..31e09de 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -19,6 +19,7 @@ from app.models.coupon_state import ( # noqa: F401 CouponDailyCompletion, CouponPromptEngagement, ) +from app.models.cps_order import CpsOrder # noqa: F401 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 diff --git a/app/schemas/compare_record.py b/app/schemas/compare_record.py index bd84735..91ccd36 100644 --- a/app/schemas/compare_record.py +++ b/app/schemas/compare_record.py @@ -123,6 +123,7 @@ class ComparisonRecordIn(BaseModel): # pricebot done.params.trace_url 原样上报,落库供记录页「复制调试链接」(dir 名含落盘 # 时分秒前端拼不出,必须由后端透传)。 trace_url: str | None = Field(None, description="本次比价公网调试链接") + total_ms: int | None = Field(None, description="整场比价墙钟耗时(ms)") # ===== debug 维度(客户端采集上报;旧客户端不带 → None。仅 admin 比价记录页用)===== # 必须显式声明,否则 model_dump() 落 raw_payload 时被 pydantic 静默丢弃(同上面 coupon_saved 的坑)。 @@ -172,6 +173,7 @@ class ComparisonRecordOut(BaseModel): items: list = [] comparison_results: list = [] skipped_dish_names: list = [] + total_ms: int | None = None # 「已下单」(店级):该店名在该用户真实下单(source='compare')里出现过即 True。 # 由 list_records 动态算出挂在 ORM 实例上(非 DB 列),from_attributes 读出;缺省 False。 ordered: bool = False diff --git a/docs/guides/邀请功能-实现原理与本地测试.md b/docs/guides/邀请功能-实现原理与本地测试.md index 1a8b147..e2f8821 100644 --- a/docs/guides/邀请功能-实现原理与本地测试.md +++ b/docs/guides/邀请功能-实现原理与本地测试.md @@ -37,7 +37,7 @@ B 安装并首启 App └─ POST /api/v1/invite/bind { invite_code, channel="clipboard" } ↓ 后端 repositories/invite.py bind() - └─ 过四道防线 → 建 invite_relation + 给 A、B 各发金币(同事务原子提交) + └─ 过四道防线 → 建 invite_relation(邀请金币已下线,不写金币流水) ``` 手动填码这条:B 在邀请页输码 → `InviteRepository.bindManual()` → `POST /bind { channel="manual" }` → 同一个 `bind()`。 @@ -52,20 +52,20 @@ B 安装并首启 App |---|---| | 端点 | `app/api/v1/invite.py`:`GET /api/v1/invite/me`(返回 `invite_code` + `share_url` + 战绩)、`POST /api/v1/invite/bind`(绑定,`channel` = `clipboard` / `manual`)。**均需 Bearer 鉴权**。 | | share_url 构造 | `invite.py` 的 `my_invite`:`settings.INVITE_LANDING_URL + "?ref=" + code`。`INVITE_LANDING_URL` 在 `app/core/config.py`(默认 `https://app-api.shaguabijia.com/media/dl.html`)。 | -| 业务逻辑 | `app/repositories/invite.py`:`ensure_code`(懒生成 6 位邀请码,去混淆字符集,唯一约束碰撞则换码)/ `resolve_inviter`(邀请码→邀请人,大小写不敏感)/ `bind`(下面详述)/ `get_stats`(已邀人数 + 累计金币)。 | +| 业务逻辑 | `app/repositories/invite.py`:`ensure_code`(懒生成 6 位邀请码,去混淆字符集,唯一约束碰撞则换码)/ `resolve_inviter`(邀请码→邀请人,大小写不敏感)/ `bind`(下面详述)/ `get_stats`(已邀人数 + 兼容累计金币字段,当前恒为 0)。 | | 数据模型 | `app/models/invite.py` 的 `InviteRelation`(`inviter_user_id` / `invitee_user_id` / `channel` / `status` / `inviter_coin` / `invitee_coin` / `created_at`)+ `app/models/user.py` 的 `User.invite_code` 列。 | | 迁移 | `alembic/versions/invite_code_and_relation.py`:给 `user` 加 `invite_code`(唯一索引)+ 建 `invite_relation` 表。`down_revision = 11a1d08c6f55`。 | | 收发模型 | `app/schemas/invite.py`:`InviteInfoOut` / `BindInviteIn` / `BindInviteOut`。 | -| 奖励常量 | `app/core/rewards.py`:`INVITE_INVITER_COINS` / `INVITE_INVITEE_COINS`(各 10000 = 1 元)、`INVITE_NEW_USER_WINDOW_HOURS`(72)。 | +| 新人窗口 | `app/core/rewards.py`:`INVITE_NEW_USER_WINDOW_HOURS`(72)。邀请金币已下线,不再配置邀请金币常量。 | **`bind()` 的四道防线(防重复 / 防刷,看 `repositories/invite.py`):** -1. **被邀请人唯一**:`invitee_user_id` 唯一约束 → 一个 B 只能被绑一次(幂等键,重复返回 `already_bound`,不重复发奖)。 +1. **被邀请人唯一**:`invitee_user_id` 唯一约束 → 一个 B 只能被绑一次(幂等键,重复返回 `already_bound`,不重复绑定)。 2. **自邀屏蔽**:`inviter == invitee` → `self_invite`。 -3. **新人闸**:`_is_new_user`(B 的 `created_at` 在 `INVITE_NEW_USER_WINDOW_HOURS` = 72h 内)才发奖,挡存量老用户互相填码薅羊毛 → 否则 `not_eligible`。 +3. **新人闸**:`_is_new_user`(B 的 `created_at` 在 `INVITE_NEW_USER_WINDOW_HOURS` = 72h 内)才生效,挡存量老用户互相填码刷关系 → 否则 `not_eligible`。 4. **手机号唯一**(天然限量):每个 B = 一个真实手机号账号。 -发金币复用 `repositories/wallet.py` 的 `grant_coins`,与建关系记录在**同一事务**提交,保证"建关系 + 双方加金币"原子。 +邀请金币已下线:`bind()` 只记录绑定关系,不再写 `coin_transaction`;响应里的金币字段保留兼容旧客户端,当前恒为 0。 ### 3.2 前端(shaguabijia-app-android) @@ -120,7 +120,7 @@ B 安装并首启 App ### 4.4 测试硬约束 / 坑(都是机制,不是 bug) - **B 必须用新手机号**:`invitee_user_id` 唯一,一个 B 只能绑一次;反复测要换号(或手删 `invite_relation` 那行 + 回滚金币)。 -- **72h 新人闸**:B 注册后 72 小时内绑才发奖(刚注册肯定满足)。 +- **72h 新人闸**:B 注册后 72 小时内绑定才生效(刚注册肯定满足)。 - **A ≠ B**:自邀被屏蔽。 - **B 从点下载到首启 App 之间别复制别的东西**:剪贴板会被覆盖 → 归因丢(剪贴板 deferred deeplink 的固有脆弱性)。 - **笔记本 IP 别变**:debug 包把 `BASE_URL` 的 IP 烧死在编译期,DHCP 一换就连不上 → 给笔记本固定个 LAN IP。 diff --git a/tests/test_ad_reward.py b/tests/test_ad_reward.py index 00b85c0..ebc0c98 100644 --- a/tests/test_ad_reward.py +++ b/tests/test_ad_reward.py @@ -305,11 +305,12 @@ def test_feed_reward_grants_by_10_second_units(client) -> None: "duration_seconds": 30, "adn": "pangle", "slot_id": "slot_feed", + "display_coin": 4, } r = client.post("/api/v1/ad/feed-reward", json=payload, headers=_auth(token)) assert r.status_code == 200, r.text body = r.json() - expected = sum(calculate_ad_reward_coin("200", i) for i in range(1, 4)) + expected = 4 assert body["granted"] is True assert body["status"] == "granted" assert body["unit_count"] == 3 diff --git a/tests/test_admin_read.py b/tests/test_admin_read.py index 22d5efc..3d71eae 100644 --- a/tests/test_admin_read.py +++ b/tests/test_admin_read.py @@ -64,7 +64,8 @@ def test_dashboard_overview(admin_client: TestClient, admin_token: str) -> None: assert data["users"]["total"] >= 1 assert data["coins"]["granted_total"] >= 5000 assert "success_rate" in data["comparison"] - assert data["cps"]["available"] is False + assert data["cps"]["available"] is True + assert "meituan_order_count" in data["cps"] def test_user_list_and_detail(admin_client: TestClient, admin_token: str) -> None: diff --git a/tests/test_compare_record.py b/tests/test_compare_record.py index 85696f0..38548e1 100644 --- a/tests/test_compare_record.py +++ b/tests/test_compare_record.py @@ -44,6 +44,7 @@ def _food_payload(trace_id: str) -> dict: "skipped_dish_names": ["黑牛肉卷"], "total_dish_count": 3, "information": "在美团找到同店,到手价 ¥123.50", + "total_ms": 12345, } @@ -71,6 +72,8 @@ def test_report_and_derive(client) -> None: assert d["information"] == "在美团找到同店,到手价 ¥123.50" assert d["store_name"] == "海底捞(朝阳店)" assert d["total_dish_count"] == 3 + assert d["total_ms"] == 12345 + assert d["raw_payload"]["total_ms"] == 12345 assert d["skipped_dish_count"] == 1 assert d["skipped_dish_names"] == ["黑牛肉卷"] assert len(d["comparison_results"]) == 3 diff --git a/tools/meituan_playground.py b/tools/meituan_playground.py index 0e9f165..0664d36 100644 --- a/tools/meituan_playground.py +++ b/tools/meituan_playground.py @@ -52,7 +52,8 @@ def call_raw(path: str, body_obj: dict) -> dict: } url = f"{settings.MT_CPS_HOST}{path}" t0 = time.time() - resp = httpx.post(url, content=body, headers=headers, timeout=settings.MT_CPS_TIMEOUT_SEC) + # trust_env=False: 美团是国内域名,强制直连绕开本机代理(代理会掐断 TLS 握手,报 SSL EOF) + resp = httpx.post(url, content=body, headers=headers, timeout=settings.MT_CPS_TIMEOUT_SEC, trust_env=False) ms = int((time.time() - t0) * 1000) try: j = resp.json()