Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6e5ce2f6e |
@@ -1,32 +0,0 @@
|
||||
"""coupon_session 加 origin_package 列(发起来源 App 包名 → admin「发起平台」)
|
||||
|
||||
null=App 内(傻瓜比价首页)发起,非空=从美团/淘宝/京东弹券发起。与 trace_url 同理单独成迁移,
|
||||
已建表环境靠它补列、全新环境顺序应用,不重复加列。
|
||||
|
||||
Revision ID: coupon_session_origin_package
|
||||
Revises: coupon_session_trace_url
|
||||
Create Date: 2026-06-30 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_session_origin_package"
|
||||
down_revision: str | Sequence[str] | None = "coupon_session_trace_url"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("origin_package", sa.String(length=64), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
|
||||
batch_op.drop_column("origin_package")
|
||||
@@ -1,81 +0,0 @@
|
||||
"""coupon session table(领券任务全程流水 → admin「领券数据」看板数据源)
|
||||
|
||||
一次领券一行(trace_id 唯一):客户端 POST /api/v1/coupon/session 两段上报 —— 发起建行
|
||||
(status=started)、收尾(completed/failed/abandoned)按 trace_id 更新同一行。记全程耗时
|
||||
elapsed_ms + 各平台耗时 platform_elapsed + 机型/ROM,供 admin 算发起/完成数、耗时分位、机型维度。
|
||||
|
||||
Revision ID: coupon_session_table
|
||||
Revises: feedback_submit_env
|
||||
Create Date: 2026-06-30 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "coupon_session_table"
|
||||
down_revision: str | Sequence[str] | None = "feedback_submit_env"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
# PG 用 JSONB,SQLite(本地/测试)退化为通用 JSON(同 model 的 _JSON variant)。
|
||||
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"coupon_session",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("trace_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("device_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("status", sa.String(length=16), nullable=False),
|
||||
sa.Column("app_env", sa.String(length=16), nullable=True),
|
||||
sa.Column("platforms", _JSON, nullable=True),
|
||||
sa.Column("device_model", sa.String(length=128), nullable=True),
|
||||
sa.Column("rom", sa.String(length=64), nullable=True),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("started_date", sa.Date(), nullable=False),
|
||||
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("elapsed_ms", sa.Integer(), nullable=True),
|
||||
sa.Column("platform_elapsed", _JSON, nullable=True),
|
||||
sa.Column("claimed_count", sa.Integer(), 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("trace_id", name="uq_coupon_session_trace"),
|
||||
)
|
||||
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
|
||||
batch_op.create_index(
|
||||
batch_op.f("ix_coupon_session_user_id"), ["user_id"], unique=False
|
||||
)
|
||||
batch_op.create_index(
|
||||
batch_op.f("ix_coupon_session_app_env"), ["app_env"], unique=False
|
||||
)
|
||||
# admin 主聚合/筛选:按上海自然日 + 环境。
|
||||
batch_op.create_index(
|
||||
"ix_coupon_session_date_env", ["started_date", "app_env"], unique=False
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
|
||||
batch_op.drop_index("ix_coupon_session_date_env")
|
||||
batch_op.drop_index(batch_op.f("ix_coupon_session_app_env"))
|
||||
batch_op.drop_index(batch_op.f("ix_coupon_session_user_id"))
|
||||
op.drop_table("coupon_session")
|
||||
@@ -1,32 +0,0 @@
|
||||
"""coupon_session 加 trace_url 列(pricebot done 帧公网调试链接)
|
||||
|
||||
建表迁移 coupon_session_table 落地后才追加本列,故单独成一个迁移:已建表的环境(本地/已 upgrade 过)
|
||||
靠它补列,全新环境则「建表(无 trace_url)→ 本迁移加列」,两条路一致、不重复加列。
|
||||
|
||||
Revision ID: coupon_session_trace_url
|
||||
Revises: coupon_session_table
|
||||
Create Date: 2026-06-30 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_session_trace_url"
|
||||
down_revision: str | Sequence[str] | None = "coupon_session_table"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("trace_url", sa.String(length=512), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
|
||||
batch_op.drop_column("trace_url")
|
||||
@@ -21,7 +21,6 @@ from app.admin.routers.audit import router as audit_router
|
||||
from app.admin.routers.auth import router as auth_router
|
||||
from app.admin.routers.comparison import router as comparison_router
|
||||
from app.admin.routers.config import router as config_router
|
||||
from app.admin.routers.coupon_data import router as coupon_data_router
|
||||
from app.admin.routers.cps import router as cps_router
|
||||
from app.admin.routers.dashboard import router as dashboard_router
|
||||
from app.admin.routers.device_liveness import router as device_liveness_router
|
||||
@@ -102,7 +101,6 @@ admin_app.include_router(audit_router)
|
||||
admin_app.include_router(config_router)
|
||||
admin_app.include_router(comparison_router)
|
||||
admin_app.include_router(cps_router)
|
||||
admin_app.include_router(coupon_data_router)
|
||||
admin_app.include_router(ad_audit_router)
|
||||
admin_app.include_router(ad_config_router)
|
||||
admin_app.include_router(ad_revenue_router)
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
"""admin「领券数据」看板聚合:发起/完成数、耗时均值与分位、按天/小时趋势、逐条明细。
|
||||
|
||||
数据源 coupon_session(一次领券一行,客户端 /api/v1/coupon/session 两段上报)。量级不大,全量拉
|
||||
区间数据后 Python 聚合(分位 SQLite 无 percentile,统一 Python 算,PG 上也一致)。
|
||||
- 发起数 = 区间内全部 session(含 started/completed/failed/abandoned),= 流失统计的基数。
|
||||
- 完成数 / 耗时均值 / 分位 = 仅 status==completed 子集(成功跑完才有可比的"领券耗时")。
|
||||
- summary/daily/hourly/total 在全量上算,不受分页;items 为排序后当前页。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date as _date, datetime
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
from app.models.coupon_state import CouponSession
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def _cn_hour(dt: datetime) -> int:
|
||||
"""started_at(UTC 口径)→ 北京时间小时(0–23)。naive 当 UTC(sqlite),tz-aware 直接换算(pg)。"""
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
return dt.astimezone(rewards.CN_TZ).hour
|
||||
|
||||
|
||||
def _percentile(sorted_vals: list[int], q: float) -> int | None:
|
||||
"""线性插值分位(q=0..100,numpy 默认法)。sorted_vals 须已升序;空返回 None。"""
|
||||
if not sorted_vals:
|
||||
return None
|
||||
if len(sorted_vals) == 1:
|
||||
return sorted_vals[0]
|
||||
idx = (len(sorted_vals) - 1) * q / 100.0
|
||||
lo = int(idx)
|
||||
hi = min(lo + 1, len(sorted_vals) - 1)
|
||||
frac = idx - lo
|
||||
return round(sorted_vals[lo] * (1 - frac) + sorted_vals[hi] * frac)
|
||||
|
||||
|
||||
def _avg(vals: list[int]) -> int | None:
|
||||
return round(sum(vals) / len(vals)) if vals else None
|
||||
|
||||
|
||||
def _session_to_row(r, phone: str | None = None, nickname: str | None = None) -> dict:
|
||||
"""CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。"""
|
||||
return {
|
||||
"id": r.id,
|
||||
"trace_id": r.trace_id,
|
||||
"user_id": r.user_id,
|
||||
"user_phone": phone,
|
||||
"user_nickname": nickname,
|
||||
"status": r.status,
|
||||
"platforms": r.platforms,
|
||||
"origin_package": r.origin_package,
|
||||
"elapsed_ms": r.elapsed_ms,
|
||||
"platform_elapsed": r.platform_elapsed,
|
||||
"device_model": r.device_model,
|
||||
"rom": r.rom,
|
||||
"app_env": r.app_env,
|
||||
"started_at": r.started_at,
|
||||
"claimed_count": r.claimed_count,
|
||||
"trace_url": r.trace_url,
|
||||
}
|
||||
|
||||
|
||||
def _empty_result() -> dict:
|
||||
return {
|
||||
"summary": {
|
||||
"started_count": 0, "completed_count": 0, "avg_elapsed_ms": None,
|
||||
"p5_ms": None, "p50_ms": None, "p95_ms": None, "p99_ms": None,
|
||||
},
|
||||
"daily": [],
|
||||
"hourly": [],
|
||||
"total": 0,
|
||||
"items": [],
|
||||
}
|
||||
|
||||
|
||||
def coupon_data_report(
|
||||
db: Session,
|
||||
*,
|
||||
date_from: str,
|
||||
date_to: str,
|
||||
user: str | None = None,
|
||||
app_env: str | None = None,
|
||||
granularity: str = "day",
|
||||
limit: int = 500,
|
||||
offset: int = 0,
|
||||
sort: str = "time",
|
||||
) -> dict:
|
||||
"""日期区间(北京自然日 started_date,闭区间)领券数据:汇总卡 + 趋势 + 逐条明细。
|
||||
|
||||
- user:手机号/昵称模糊搜(匹配不到任何用户 → 空结果)。
|
||||
- app_env:prod/dev 精确;None=全部。
|
||||
- sort:time=发起时刻倒序(默认) / elapsed=全程耗时倒序(None 末尾)。
|
||||
"""
|
||||
by_hour = granularity == "hour"
|
||||
d_from = _date.fromisoformat(date_from)
|
||||
d_to = _date.fromisoformat(date_to)
|
||||
|
||||
# user 模糊 → 先定位匹配用户 id;匹配不到直接空结果(不全表扫)。
|
||||
user_ids: set[int] | None = None
|
||||
if user:
|
||||
like = f"%{user}%"
|
||||
user_ids = set(db.execute(
|
||||
select(User.id).where(or_(User.phone.like(like), User.nickname.like(like)))
|
||||
).scalars().all())
|
||||
if not user_ids:
|
||||
return _empty_result()
|
||||
|
||||
stmt = select(CouponSession).where(
|
||||
CouponSession.started_date >= d_from,
|
||||
CouponSession.started_date <= d_to,
|
||||
)
|
||||
if app_env is not None:
|
||||
stmt = stmt.where(CouponSession.app_env == app_env)
|
||||
if user_ids is not None:
|
||||
stmt = stmt.where(CouponSession.user_id.in_(user_ids))
|
||||
rows = list(db.execute(stmt).scalars())
|
||||
|
||||
# ── 汇总卡 ──
|
||||
completed_elapsed = sorted(
|
||||
r.elapsed_ms for r in rows if r.status == "completed" and r.elapsed_ms is not None
|
||||
)
|
||||
summary = {
|
||||
"started_count": len(rows),
|
||||
"completed_count": sum(1 for r in rows if r.status == "completed"),
|
||||
"avg_elapsed_ms": _avg(completed_elapsed),
|
||||
"p5_ms": _percentile(completed_elapsed, 5),
|
||||
"p50_ms": _percentile(completed_elapsed, 50),
|
||||
"p95_ms": _percentile(completed_elapsed, 95),
|
||||
"p99_ms": _percentile(completed_elapsed, 99),
|
||||
}
|
||||
|
||||
# ── 按天趋势(柱=发起/完成数,线=平均耗时)──
|
||||
daily_map: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
d = r.started_date.isoformat()
|
||||
b = daily_map.get(d)
|
||||
if b is None:
|
||||
b = {"date": d, "started_count": 0, "completed_count": 0, "_elapsed": []}
|
||||
daily_map[d] = b
|
||||
b["started_count"] += 1
|
||||
if r.status == "completed":
|
||||
b["completed_count"] += 1
|
||||
if r.elapsed_ms is not None:
|
||||
b["_elapsed"].append(r.elapsed_ms)
|
||||
daily = [
|
||||
{
|
||||
"date": b["date"],
|
||||
"started_count": b["started_count"],
|
||||
"completed_count": b["completed_count"],
|
||||
"avg_elapsed_ms": _avg(b["_elapsed"]),
|
||||
}
|
||||
for b in sorted(daily_map.values(), key=lambda x: x["date"])
|
||||
]
|
||||
|
||||
# ── 按小时趋势(单日 hour 粒度)──
|
||||
hourly: list[dict] = []
|
||||
if by_hour:
|
||||
hour_map: dict[int, dict] = {}
|
||||
for r in rows:
|
||||
h = _cn_hour(r.started_at)
|
||||
b = hour_map.get(h)
|
||||
if b is None:
|
||||
b = {"hour": h, "started_count": 0, "completed_count": 0, "_elapsed": []}
|
||||
hour_map[h] = b
|
||||
b["started_count"] += 1
|
||||
if r.status == "completed":
|
||||
b["completed_count"] += 1
|
||||
if r.elapsed_ms is not None:
|
||||
b["_elapsed"].append(r.elapsed_ms)
|
||||
hourly = [
|
||||
{
|
||||
"hour": b["hour"],
|
||||
"started_count": b["started_count"],
|
||||
"completed_count": b["completed_count"],
|
||||
"avg_elapsed_ms": _avg(b["_elapsed"]),
|
||||
}
|
||||
for b in sorted(hour_map.values(), key=lambda x: x["hour"])
|
||||
]
|
||||
|
||||
# ── 明细:排序 + 分页 + 补用户手机号/昵称(批量,防 N+1)──
|
||||
if sort == "elapsed":
|
||||
rows.sort(key=lambda r: (r.elapsed_ms is None, -(r.elapsed_ms or 0)))
|
||||
else: # time:发起时刻倒序
|
||||
rows.sort(key=lambda r: r.started_at, reverse=True)
|
||||
page = rows[offset:offset + limit]
|
||||
|
||||
uids = {r.user_id for r in page if r.user_id is not None}
|
||||
user_map: dict[int, tuple[str | None, str | None]] = {}
|
||||
if uids:
|
||||
user_map = {
|
||||
uid: (phone, nickname)
|
||||
for uid, phone, nickname in db.execute(
|
||||
select(User.id, User.phone, User.nickname).where(User.id.in_(uids))
|
||||
).all()
|
||||
}
|
||||
items = []
|
||||
for r in page:
|
||||
phone, nickname = user_map.get(r.user_id, (None, None)) if r.user_id is not None else (None, None)
|
||||
items.append(_session_to_row(r, phone, nickname))
|
||||
|
||||
return {
|
||||
"summary": summary,
|
||||
"daily": daily,
|
||||
"hourly": hourly,
|
||||
"total": len(rows),
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
def coupon_user_records(db: Session, *, user_id: int, limit: int = 100) -> dict:
|
||||
"""某用户全部领券记录(点手机号抽屉用):按发起时刻倒序、不限日期,total=该用户领券总次数。"""
|
||||
rows = list(db.execute(
|
||||
select(CouponSession)
|
||||
.where(CouponSession.user_id == user_id)
|
||||
.order_by(CouponSession.started_at.desc())
|
||||
.limit(limit)
|
||||
).scalars())
|
||||
total = db.execute(
|
||||
select(func.count()).select_from(CouponSession).where(CouponSession.user_id == user_id)
|
||||
).scalar_one()
|
||||
return {"items": [_session_to_row(r) for r in rows], "total": int(total)}
|
||||
@@ -1,106 +0,0 @@
|
||||
"""admin「领券数据」看板:发起/完成数 + 领券耗时(均值 + P5/P50/P95/P99)+ 按天趋势 + 逐条明细。
|
||||
|
||||
任意已登录 admin 可看(只读)。聚合逻辑在 app/admin/repositories/coupon_data.py。
|
||||
数据源 coupon_session(客户端 /api/v1/coupon/session 两段上报)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date as _date
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from app.admin.deps import AdminDb, get_current_admin
|
||||
from app.admin.repositories import coupon_data
|
||||
from app.admin.schemas.coupon_data import (
|
||||
CouponDataDaily,
|
||||
CouponDataHourly,
|
||||
CouponDataOut,
|
||||
CouponDataRow,
|
||||
CouponDataSummary,
|
||||
CouponUserRecordsOut,
|
||||
)
|
||||
from app.core.rewards import cn_today
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/coupon-data",
|
||||
tags=["admin-coupon-data"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
# 区间最大跨度(天);超出拒绝,避免一次拉过多天拖垮接口(对齐广告收益报表)。
|
||||
_MAX_RANGE_DAYS = 92
|
||||
|
||||
|
||||
def _parse_day(value: str | None, *, field: str, default: _date) -> _date:
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return _date.fromisoformat(value)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=f"{field} 需为 YYYY-MM-DD") from e
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=CouponDataOut,
|
||||
summary="领券数据看板(发起/完成数 + 耗时分位 + 按天趋势 + 逐条明细)",
|
||||
)
|
||||
def get_coupon_data(
|
||||
db: AdminDb,
|
||||
date_from: Annotated[str | None, Query(description="起始日 北京 YYYY-MM-DD,默认今天")] = None,
|
||||
date_to: Annotated[str | None, Query(description="结束日 北京 YYYY-MM-DD,闭区间,默认=date_from")] = None,
|
||||
user: Annotated[str | None, Query(description="用户手机号/昵称模糊搜;不传=全部")] = None,
|
||||
app_env: Annotated[str, Query(description="prod(默认) / dev / all(全部环境)")] = "prod",
|
||||
granularity: Annotated[
|
||||
str, Query(description="day=按天 / hour=按小时(北京);区间>1 天建议 day")
|
||||
] = "day",
|
||||
limit: Annotated[int, Query(ge=1, le=1000, description="每页条数(分页大小)")] = 500,
|
||||
offset: Annotated[int, Query(ge=0, description="分页偏移(已跳过条数)=(页码-1)×每页条数")] = 0,
|
||||
sort: Annotated[
|
||||
str, Query(description="排序:time=发起时间倒序(默认) / elapsed=耗时倒序")
|
||||
] = "time",
|
||||
) -> CouponDataOut:
|
||||
today = cn_today()
|
||||
d_from = _parse_day(date_from, field="date_from", default=today)
|
||||
d_to = _parse_day(date_to, field="date_to", default=d_from)
|
||||
if d_to < d_from:
|
||||
raise HTTPException(status_code=422, detail="date_to 不能早于 date_from")
|
||||
if (d_to - d_from).days + 1 > _MAX_RANGE_DAYS:
|
||||
raise HTTPException(status_code=422, detail=f"区间最长 {_MAX_RANGE_DAYS} 天")
|
||||
|
||||
# 报表默认只看 prod(对齐广告报表防串台口径);app_env=all 时不过滤、看全部环境。
|
||||
env = None if app_env == "all" else app_env
|
||||
result = coupon_data.coupon_data_report(
|
||||
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(),
|
||||
user=user, app_env=env, granularity=granularity,
|
||||
limit=limit, offset=offset, sort=sort,
|
||||
)
|
||||
return CouponDataOut(
|
||||
date_from=d_from.isoformat(),
|
||||
date_to=d_to.isoformat(),
|
||||
summary=CouponDataSummary(**result["summary"]),
|
||||
daily=[CouponDataDaily(**d) for d in result["daily"]],
|
||||
hourly=[CouponDataHourly(**h) for h in result["hourly"]],
|
||||
total=result["total"],
|
||||
items=[CouponDataRow(**r) for r in result["items"]],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/user-records",
|
||||
response_model=CouponUserRecordsOut,
|
||||
summary="某用户全部领券记录(点手机号抽屉:领券次数 + 记录列表)",
|
||||
)
|
||||
def get_user_coupon_records(
|
||||
db: AdminDb,
|
||||
user_id: Annotated[int, Query(description="用户 id")],
|
||||
limit: Annotated[int, Query(ge=1, le=500, description="最多返回条数")] = 100,
|
||||
sort_by: Annotated[str, Query(description="兼容 UserRecordsDrawer 参数;固定按发起时间倒序")] = "created_at",
|
||||
sort_order: Annotated[str, Query(description="兼容参数,忽略")] = "desc",
|
||||
) -> CouponUserRecordsOut:
|
||||
result = coupon_data.coupon_user_records(db, user_id=user_id, limit=limit)
|
||||
return CouponUserRecordsOut(
|
||||
items=[CouponDataRow(**r) for r in result["items"]],
|
||||
total=result["total"],
|
||||
)
|
||||
@@ -1,83 +0,0 @@
|
||||
"""admin「领券数据」看板 schemas:汇总卡 + 按天/小时趋势 + 逐条领券明细。
|
||||
|
||||
数据源 coupon_session(一次领券一行)。耗时单位 ms(前端按需折秒);均值/分位只统计 completed。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CouponDataSummary(BaseModel):
|
||||
"""汇总卡:发起/完成数 + 耗时均值与分位(P5/P50/P95/P99,基于 completed 的 elapsed_ms)。"""
|
||||
|
||||
started_count: int = Field(..., description="发起数(区间内所有领券 session)")
|
||||
completed_count: int = Field(..., description="完成数(status=completed)")
|
||||
avg_elapsed_ms: int | None = Field(None, description="平均耗时(ms,仅 completed;无数据为空)")
|
||||
p5_ms: int | None = Field(None, description="耗时 5 分位(ms)")
|
||||
p50_ms: int | None = Field(None, description="耗时 50 分位(ms,中位数)")
|
||||
p95_ms: int | None = Field(None, description="耗时 95 分位(ms)")
|
||||
p99_ms: int | None = Field(None, description="耗时 99 分位(ms)")
|
||||
|
||||
|
||||
class CouponDataDaily(BaseModel):
|
||||
"""按天趋势(全量,不受分页影响):柱=发起/完成数,线=平均耗时。"""
|
||||
|
||||
date: str = Field(..., description="北京时间 YYYY-MM-DD")
|
||||
started_count: int
|
||||
completed_count: int
|
||||
avg_elapsed_ms: int | None = Field(None, description="当天平均耗时(ms,仅 completed)")
|
||||
|
||||
|
||||
class CouponDataHourly(BaseModel):
|
||||
"""按北京小时(0–23)趋势(单日 granularity=hour 时非空)。"""
|
||||
|
||||
hour: int = Field(..., description="北京时间小时 0–23")
|
||||
started_count: int
|
||||
completed_count: int
|
||||
avg_elapsed_ms: int | None = None
|
||||
|
||||
|
||||
class CouponDataRow(BaseModel):
|
||||
"""一条领券明细(一次领券任务)。"""
|
||||
|
||||
id: int = Field(..., description="coupon_session 主键(抽屉 rowKey 用)")
|
||||
trace_id: str
|
||||
user_id: int | None = None
|
||||
user_phone: str | None = Field(None, description="手机号(admin 展示;匿名领券/查不到为空)")
|
||||
user_nickname: str | None = Field(None, description="昵称")
|
||||
status: str = Field(..., description="started / completed / failed / abandoned")
|
||||
platforms: list[str] | None = Field(None, description="发起勾选平台")
|
||||
origin_package: str | None = Field(None, description="发起来源 App 包名;null=App 内(傻瓜比价首页)发起")
|
||||
elapsed_ms: int | None = Field(None, description="全程耗时(ms)")
|
||||
platform_elapsed: dict[str, int] | None = Field(
|
||||
None, description="各平台耗时 {meituan-waimai/taobao-shanguang/jd-waimai: ms}"
|
||||
)
|
||||
device_model: str | None = None
|
||||
rom: str | None = None
|
||||
app_env: str | None = None
|
||||
started_at: datetime = Field(..., description="发起时刻(明细「时间」列)")
|
||||
claimed_count: int | None = None
|
||||
trace_url: str | None = Field(None, description="pricebot 公网 trace 链接(仅 completed 有);admin 渲染可点链接,无则显示可复制 trace_id")
|
||||
|
||||
|
||||
class CouponDataOut(BaseModel):
|
||||
"""领券数据看板响应:汇总卡 + 趋势 + 明细分页。"""
|
||||
|
||||
date_from: str
|
||||
date_to: str
|
||||
summary: CouponDataSummary
|
||||
daily: list[CouponDataDaily] = Field(default_factory=list, description="按天趋势(全量)")
|
||||
hourly: list[CouponDataHourly] = Field(
|
||||
default_factory=list, description="按小时趋势(单日 hour 粒度时非空)"
|
||||
)
|
||||
total: int = Field(..., description="明细总条数(全量,不受分页)")
|
||||
items: list[CouponDataRow] = Field(..., description="逐条领券明细(当前页)")
|
||||
|
||||
|
||||
class CouponUserRecordsOut(BaseModel):
|
||||
"""某用户全部领券记录(点手机号抽屉用):total=该用户领券总次数,items=记录列表(UserRecordsDrawer 渲染)。"""
|
||||
|
||||
items: list[CouponDataRow]
|
||||
total: int
|
||||
@@ -21,15 +21,16 @@ from app.api.deps import CurrentUser, DbSession
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.repositories import comparison as crud_compare
|
||||
from app.repositories import invite as crud_invite
|
||||
from app.services.pricebot_llm_calls import fetch_llm_calls
|
||||
from app.schemas.compare_record import (
|
||||
CompareStatsOut,
|
||||
ComparisonRecordCreatedOut,
|
||||
ComparisonRecordDetailOut,
|
||||
ComparisonRecordIn,
|
||||
ComparisonRecordOut,
|
||||
ComparisonRecordPage,
|
||||
ComparisonRecordOut,
|
||||
)
|
||||
from app.services.pricebot_llm_calls import fetch_llm_calls
|
||||
|
||||
logger = logging.getLogger("shagua.compare_record")
|
||||
|
||||
@@ -52,8 +53,18 @@ def report_record(
|
||||
# 任务做,不阻塞上报响应(顺带给 pricebot 落盘留足余量)。upsert 已 commit,后台用
|
||||
# 独立 session 按 record id 回填 llm_calls + 派生 llm_call_count/retry_count。
|
||||
background_tasks.add_task(_backfill_llm_calls, rec.id, rec.trace_id)
|
||||
# 注:邀请发奖已从"比价成功"挪到"实际下单"(见 api/v1/order.py report_order)——
|
||||
# 冰拍板:被邀请人完成比价并实际下单才算邀请成功,仅完成比价不再发奖。
|
||||
# 邀请 v2 发奖:被邀请人完成一次【成功】比价 → 给邀请人发邀请奖励金(幂等,只发一次)。
|
||||
# best-effort:发奖异常不影响比价上报本身(rec 已 commit),只 log;邀请人补偿靠后续对账。
|
||||
if rec.status == "success":
|
||||
try:
|
||||
reward = crud_invite.try_reward_on_compare(db, user.id)
|
||||
if reward.status == "granted":
|
||||
logger.info(
|
||||
"invite compare reward granted inviter=%s invitee=%s cents=%s",
|
||||
reward.inviter_user_id, user.id, reward.reward_cents,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 best-effort,发奖失败不阻塞上报
|
||||
logger.warning("invite compare reward failed invitee=%s: %s", user.id, e)
|
||||
logger.info(
|
||||
"compare record user=%s trace=%s biz=%s status=%s saved=%s (llm_calls backfill queued)",
|
||||
user.id,
|
||||
|
||||
@@ -29,7 +29,6 @@ from app.schemas.coupon_state import (
|
||||
CouponPromptDismissIn,
|
||||
CouponPromptShouldShowOut,
|
||||
CouponPromptShownIn,
|
||||
CouponSessionIn,
|
||||
CouponStatsOut,
|
||||
)
|
||||
|
||||
@@ -195,34 +194,6 @@ async def coupon_step(
|
||||
return resp_json
|
||||
|
||||
|
||||
@router.post("/session", summary="领券任务流水上报(admin 领券数据看板数据源)")
|
||||
def coupon_session(payload: CouponSessionIn, db: DbSession) -> dict[str, bool]:
|
||||
"""客户端两段上报一次领券流水(发起 started / 收尾 completed-failed-abandoned),按 trace_id upsert
|
||||
到 coupon_session。不鉴权(同领券循环 MVP,按 device_id/trace_id);供 admin「领券数据」看板算
|
||||
发起/完成数、耗时分位、机型维度。写库失败不应连累客户端(本就 fire-and-forget),吞掉返回 ok。"""
|
||||
try:
|
||||
coupon_repo.upsert_coupon_session(
|
||||
db,
|
||||
trace_id=payload.trace_id,
|
||||
device_id=payload.device_id,
|
||||
status=payload.status,
|
||||
started_at_ms=payload.started_at_ms,
|
||||
user_id=payload.user_id,
|
||||
platforms=payload.platforms,
|
||||
origin_package=payload.origin_package,
|
||||
device_model=payload.device_model,
|
||||
rom=payload.rom,
|
||||
app_env=payload.app_env,
|
||||
elapsed_ms=payload.elapsed_ms,
|
||||
platform_elapsed=payload.platform_elapsed,
|
||||
claimed_count=payload.claimed_count,
|
||||
trace_url=payload.trace_url,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("coupon session write failed: %s", e)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/prompt/shown", summary="领券引导窗弹出即上报(按 App 记 shown)")
|
||||
def coupon_prompt_shown(payload: CouponPromptShownIn, db: DbSession) -> dict[str, bool]:
|
||||
"""客户端弹出引导窗那刻调 → 记一条今日 engagement(shown),今天**这个 App** 不再自动弹。
|
||||
|
||||
@@ -122,57 +122,6 @@ def my_invitees(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/seed-fake", summary="[DEV] 给自己塞虚拟好友(测在途/好友列表 UI,仅非生产)")
|
||||
def seed_fake_invitees(
|
||||
user: CurrentUser, db: DbSession, pending: int = 3, compared: int = 2
|
||||
) -> dict:
|
||||
"""造 pending 个未比价 + compared 个已比价的虚拟好友。生产环境(is_prod)直接 404。
|
||||
|
||||
未比价 → 上"在途列表 / 好友列表去提醒";已比价 → 好友列表"邀请成功"、并发真实邀请奖励金
|
||||
(可提现余额/累计提现也有数)。清理见 DELETE /seed-fake。
|
||||
"""
|
||||
if settings.is_prod:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
n = invite_repo.seed_fake_invitees(db, user.id, pending=max(0, pending), compared=max(0, compared))
|
||||
return {"status": "ok", "created": n, "pending": pending, "compared": compared}
|
||||
|
||||
|
||||
@router.delete("/seed-fake", summary="[DEV] 清掉自己的虚拟好友(仅非生产)")
|
||||
def clear_fake_invitees(user: CurrentUser, db: DbSession) -> dict:
|
||||
if settings.is_prod:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
n = invite_repo.clear_fake_invitees(db, user.id)
|
||||
return {"status": "ok", "removed": n}
|
||||
|
||||
|
||||
@router.post("/dev-reset", summary="[DEV] 把固定测试号打回出厂态,便于反复当新用户测发奖(仅非生产)")
|
||||
def dev_reset(db: DbSession) -> dict:
|
||||
"""把 TEST_ACCOUNT_PHONE 这个固定测试号清成"从没被邀请/没下过单"的新用户态。
|
||||
|
||||
⚠️ 无需鉴权(它只按配置里的测试号操作,不依赖登录态,方便测试脚本第一步直接调)。
|
||||
双重门控:生产环境(is_prod)404;测试号总开关(TEST_ACCOUNT_PHONE)没配置 → 400。
|
||||
故它永远只能动那一个配置好的测试号,动不了任意真实用户。
|
||||
|
||||
清什么见 repositories/invite.dev_reset_test_account(全清 + 刷 created_at 骗过 72h 闸)。
|
||||
调完该测试号即可再次绑定 + 下单上报,重新触发一次邀请发奖。
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
if settings.is_prod:
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
phone = settings.test_account_phone
|
||||
if not phone:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="测试号未启用:请在 .env 配 TEST_ACCOUNT_PHONE=11111111111 并重启服务",
|
||||
)
|
||||
cleared = invite_repo.dev_reset_test_account(db, phone)
|
||||
logger.info("invite dev-reset phone=%s cleared=%s", phone, cleared)
|
||||
return {"status": "ok", "phone": phone, "cleared": cleared}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/landing-track",
|
||||
response_model=LandingTrackOut,
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.repositories import invite as crud_invite
|
||||
from app.repositories import savings as crud_savings
|
||||
from app.schemas.order import OrderReportOut, OrderReportRequest
|
||||
|
||||
logger = logging.getLogger("shagua.order")
|
||||
|
||||
router = APIRouter(prefix="/api/v1/order", tags=["order"])
|
||||
|
||||
|
||||
@@ -20,20 +15,6 @@ router = APIRouter(prefix="/api/v1/order", tags=["order"])
|
||||
def report_order(req: OrderReportRequest, user: CurrentUser, db: DbSession) -> OrderReportOut:
|
||||
# 记账唯一真相表是 savings_record(source='compare')。
|
||||
rec, duplicated = crud_savings.create_from_report(db, user.id, req)
|
||||
# 邀请 v2 发奖:被邀请人【实际下单】(而非仅完成比价)才给邀请人发邀请奖励金(幂等,只发一次)。
|
||||
# 触发点从"比价成功上报"挪到这里(冰:完成比价并实际下单才算成功)。仅首次真实上报触发,
|
||||
# 重复上报(duplicated)跳过。best-effort:发奖异常不影响订单上报本身(rec 已 commit),只 log;
|
||||
# try_reward_on_compare 自带 compare_reward_granted 幂等闸,漏发靠后续对账补。
|
||||
if not duplicated:
|
||||
try:
|
||||
reward = crud_invite.try_reward_on_compare(db, user.id)
|
||||
if reward.status == "granted":
|
||||
logger.info(
|
||||
"invite order reward granted inviter=%s invitee=%s cents=%s",
|
||||
reward.inviter_user_id, user.id, reward.reward_cents,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 best-effort,发奖失败不阻塞订单上报
|
||||
logger.warning("invite order reward failed invitee=%s: %s", user.id, e)
|
||||
return OrderReportOut(
|
||||
id=rec.id,
|
||||
platform=rec.platform or req.platform,
|
||||
|
||||
@@ -19,7 +19,6 @@ from app.models.coupon_state import ( # noqa: F401
|
||||
CouponClaimRecord,
|
||||
CouponDailyCompletion,
|
||||
CouponPromptEngagement,
|
||||
CouponSession,
|
||||
)
|
||||
from app.models.feedback import Feedback # noqa: F401
|
||||
from app.models.invite import InviteRelation # noqa: F401
|
||||
|
||||
@@ -181,79 +181,3 @@ class CouponPromptEngagement(Base):
|
||||
f"<CouponPromptEngagement device={self.device_id} "
|
||||
f"date={self.engage_date} type={self.engage_type}>"
|
||||
)
|
||||
|
||||
|
||||
class CouponSession(Base):
|
||||
"""一次领券任务的全程流水(admin「领券数据」看板数据源,2026-06-30)。
|
||||
|
||||
与 coupon_claim_record(按券一天一条去重)、coupon_daily_completion(按设备一天一条)都不同:
|
||||
本表**一次领券一条**(trace_id 唯一),记从发起(started)到收尾(completed/failed/abandoned)的
|
||||
全程耗时 + 各平台耗时 + 机型/ROM。客户端 POST /api/v1/coupon/session 两段上报:发起建行、
|
||||
收尾按 trace_id 更新同一行。发起即落库 → admin 可算「发起数」与中途流失(started 无终态=未完成)。
|
||||
|
||||
口径:elapsed_ms 由客户端全程计时(点发起→收尾)、权威;started_at/finished_at 为时刻留痕。
|
||||
started_date = started_at 的 Asia/Shanghai 自然日,供 admin 按天聚合 / 日期筛选(索引)。
|
||||
"""
|
||||
|
||||
__tablename__ = "coupon_session"
|
||||
__table_args__ = (
|
||||
# 一次领券一行:trace_id 幂等 upsert(发起建、收尾更新同一行)。
|
||||
UniqueConstraint("trace_id", name="uq_coupon_session_trace"),
|
||||
# admin 主聚合/筛选:按上海自然日 + 环境(报表默认只看 prod,避免测试数据串台)。
|
||||
Index("ix_coupon_session_date_env", "started_date", "app_env"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# 一次领券唯一 id(客户端 UUID,全程贯穿),upsert 键。
|
||||
trace_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
# 登录态才带(admin join 用户表出手机号/昵称);匿名领券为空。
|
||||
user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
|
||||
|
||||
# started / completed / failed / abandoned。started 无终态 = 中途流失(未完成)。
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
# prod / dev(客户端 BuildConfig.DEBUG)。admin 报表默认只看 prod(对齐广告报表防串台口径)。
|
||||
app_env: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True)
|
||||
|
||||
# 发起勾选平台 ["meituan-waimai", ...](空=全领)。
|
||||
platforms: Mapped[list | None] = mapped_column(_JSON, nullable=True)
|
||||
# 发起来源外卖 App 包名;null=App 内(傻瓜比价首页)发起,非空=从美团/淘宝/京东弹券发起。
|
||||
# admin「发起平台」列据此区分(空→傻瓜比价,包名→对应平台)。
|
||||
origin_package: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# 机型(Build.MANUFACTURER + MODEL)与 ROM(OemDetector,如 "ColorOS 14")。明细「机型/ROM」列 + 维度筛选。
|
||||
device_model: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
rom: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
# 发起时刻(客户端墙钟):明细「时间」列、趋势 X 轴。
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
# 发起的 Asia/Shanghai 自然日:按天聚合 / 日期范围筛选(索引)。
|
||||
started_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
# 收尾时刻(服务端 now);未收尾(流失)为空。
|
||||
finished_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
# 全程耗时(ms,客户端点发起→收尾):平均 / 分位都基于它(只统计 completed)。
|
||||
elapsed_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# 各平台领券耗时 {"meituan-waimai":3200,...}(ms)。明细美团/淘宝/京东耗时列。
|
||||
platform_elapsed: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
|
||||
# 领到总张数(收尾帧带)。
|
||||
claimed_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# pricebot done 帧回传的公网调试链接(price.shaguabijia.com/traces/{dir});含落盘时分秒、拼不出,只能存
|
||||
# (同 ComparisonRecord.trace_url)。admin「领券数据」明细据此渲染可点 trace 链接;未到 done(failed/abandoned)为空。
|
||||
trace_url: Mapped[str | None] = mapped_column(String(512), 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"<CouponSession trace={self.trace_id} status={self.status} "
|
||||
f"elapsed_ms={self.elapsed_ms}>"
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date, datetime, timezone
|
||||
from datetime import date, datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
@@ -17,7 +17,6 @@ from app.models.coupon_state import (
|
||||
CouponClaimRecord,
|
||||
CouponDailyCompletion,
|
||||
CouponPromptEngagement,
|
||||
CouponSession,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.coupon_state")
|
||||
@@ -233,91 +232,3 @@ def sum_claimed_count(db: Session, user_id: int) -> int:
|
||||
)
|
||||
).scalar_one()
|
||||
return int(total or 0)
|
||||
|
||||
|
||||
# ===== 领券任务流水(coupon_session,admin「领券数据」看板数据源)=====
|
||||
|
||||
def upsert_coupon_session(
|
||||
db: Session,
|
||||
*,
|
||||
trace_id: str,
|
||||
device_id: str,
|
||||
status: str,
|
||||
started_at_ms: int,
|
||||
user_id: int | None = None,
|
||||
platforms: list[str] | None = None,
|
||||
origin_package: str | None = None,
|
||||
device_model: str | None = None,
|
||||
rom: str | None = None,
|
||||
app_env: str | None = None,
|
||||
elapsed_ms: int | None = None,
|
||||
platform_elapsed: dict | None = None,
|
||||
claimed_count: int | None = None,
|
||||
trace_url: str | None = None,
|
||||
) -> None:
|
||||
"""一条领券流水按 trace_id 幂等 upsert(发起 started 建行、收尾终态更新同一行)。
|
||||
|
||||
- 乱序/重复兜底:终态(completed/failed/abandoned)先到也建行;started 重复到不覆盖已有终态
|
||||
(状态只前进,不降级)。
|
||||
- started_at 由客户端墙钟毫秒转;started_date 取其 Asia/Shanghai 自然日(admin 按天聚合/筛选)。
|
||||
- 终态帧补 finished_at=服务端 now;各字段非空才写(避免 started 帧的 None 抹掉收尾值,反之亦然)。
|
||||
并发 IntegrityError 回滚忽略(本就幂等)。
|
||||
"""
|
||||
started_at = datetime.fromtimestamp(started_at_ms / 1000, tz=timezone.utc)
|
||||
started_date = started_at.astimezone(_CN_TZ).date()
|
||||
is_terminal = status in ("completed", "failed", "abandoned")
|
||||
|
||||
row = db.execute(
|
||||
select(CouponSession).where(CouponSession.trace_id == trace_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if row is None:
|
||||
db.add(CouponSession(
|
||||
trace_id=trace_id,
|
||||
device_id=device_id,
|
||||
user_id=user_id,
|
||||
status=status,
|
||||
app_env=app_env,
|
||||
platforms=platforms,
|
||||
origin_package=origin_package,
|
||||
device_model=device_model,
|
||||
rom=rom,
|
||||
started_at=started_at,
|
||||
started_date=started_date,
|
||||
finished_at=datetime.now(timezone.utc) if is_terminal else None,
|
||||
elapsed_ms=elapsed_ms,
|
||||
platform_elapsed=platform_elapsed,
|
||||
claimed_count=claimed_count,
|
||||
trace_url=trace_url,
|
||||
))
|
||||
else:
|
||||
# 状态只前进:started 帧重复到(如 START_STICKY 重启)不把已有终态降级回 started。
|
||||
if not (status == "started" and row.status in ("completed", "failed", "abandoned")):
|
||||
row.status = status
|
||||
if is_terminal:
|
||||
row.finished_at = datetime.now(timezone.utc)
|
||||
if user_id is not None:
|
||||
row.user_id = user_id
|
||||
if platforms is not None:
|
||||
row.platforms = platforms
|
||||
if origin_package is not None:
|
||||
row.origin_package = origin_package
|
||||
if device_model is not None:
|
||||
row.device_model = device_model
|
||||
if rom is not None:
|
||||
row.rom = rom
|
||||
if app_env is not None:
|
||||
row.app_env = app_env
|
||||
if elapsed_ms is not None:
|
||||
row.elapsed_ms = elapsed_ms
|
||||
if platform_elapsed is not None:
|
||||
row.platform_elapsed = platform_elapsed
|
||||
if claimed_count is not None:
|
||||
row.claimed_count = claimed_count
|
||||
if trace_url is not None:
|
||||
row.trace_url = trace_url
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
# 并发下另一请求刚插了同 trace_id → 唯一约束撞,回滚忽略(本就幂等)。
|
||||
db.rollback()
|
||||
|
||||
@@ -304,186 +304,11 @@ def get_invitees(
|
||||
"avatar_url": u.avatar_url or u.wechat_avatar_url or None,
|
||||
"coins": rel.inviter_coin, # v3 起恒 0(邀请人收益改走邀请奖励金)
|
||||
"invited_at": rel.created_at,
|
||||
# 是否已完成过一次比价(= 已发过邀请奖励金)。客户端据此:好友列表分"去提醒/邀请成功"、
|
||||
# 在途列表只取未比价(is_compared=False)、算在途好友数与在途收益(未比价数×2元)。
|
||||
"is_compared": bool(rel.compare_reward_granted),
|
||||
})
|
||||
has_more = offset + len(rows) < int(total)
|
||||
return items, int(total), has_more
|
||||
|
||||
|
||||
# ===== 开发用:塞虚拟好友(仅非生产,方便真机测在途/好友列表 UI)=====
|
||||
|
||||
def seed_fake_invitees(db: Session, inviter_id: int, *, pending: int = 3, compared: int = 2) -> int:
|
||||
"""给 inviter 造 pending 个"未比价"+ compared 个"已比价"的虚拟被邀请好友(仅测试用)。
|
||||
|
||||
造真实 User(随机手机号/昵称/头像)+ InviteRelation;已比价的顺带发邀请奖励金(走真实入账,
|
||||
这样"可提现余额/累计"也有数)。仅非生产环境开放(endpoint 层用 is_prod 挡)。返回新建好友数。
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.repositories import user as user_repo
|
||||
|
||||
# 造点像样的中文昵称 + 打包进 APK 的真人头像(asset://,客户端转 file:///android_asset/ 本地加载,
|
||||
# 离线必显、不依赖外网,避免 dicebear 这类国外图在国内加载失败成空白)。头像图见客户端
|
||||
# app/src/main/assets/avatars/fake/p0..p7.jpg,按序号轮询分配。
|
||||
names = ["小满同学", "一罐可乐Colour", "草原上感受宁静者", "晴天小豆", "momo", "隔壁老王",
|
||||
"爱吃辣的猫", "月半子", "阿柴", "橘子汽水"]
|
||||
FAKE_AVATAR_COUNT = 8 # 与客户端 assets/avatars/fake/ 图数一致
|
||||
created = 0
|
||||
total = pending + compared
|
||||
now = datetime.now(timezone.utc)
|
||||
for i in range(total):
|
||||
is_compared = i >= pending
|
||||
# 唯一手机号:99 开头 + 9 位随机(避开真实号段,不与真人撞)
|
||||
phone = "99" + "".join(secrets.choice("0123456789") for _ in range(9))
|
||||
nickname = names[i % len(names)] + secrets.choice(["", "", "_", str(secrets.randbelow(99))])
|
||||
u = User(
|
||||
phone=phone,
|
||||
username=user_repo._gen_unique_username(db),
|
||||
nickname=nickname,
|
||||
register_channel="fake_seed",
|
||||
avatar_url=f"asset://avatars/fake/p{i % FAKE_AVATAR_COUNT}.jpg", # 打包头像,轮询分配
|
||||
status="active",
|
||||
)
|
||||
db.add(u)
|
||||
db.flush() # 拿 u.id
|
||||
rel = InviteRelation(
|
||||
inviter_user_id=inviter_id,
|
||||
invitee_user_id=u.id,
|
||||
channel="fake_seed",
|
||||
status="effective",
|
||||
inviter_coin=0,
|
||||
invitee_coin=0,
|
||||
# 邀请时间错开,便于看"时间近→远"排序
|
||||
created_at=(now - timedelta(hours=i * 6)).replace(tzinfo=None),
|
||||
)
|
||||
if is_compared:
|
||||
# 已比价 → 走真实"好友比价发奖"链路:标记 + 真发 ¥2 进邀请奖励金账户(invite_cash),
|
||||
# 让"可提现余额/累计/人数/流水/提现记录"全链路真实变化,可验证功能正确。
|
||||
# (钱只进 invite_cash,不碰金币现金账户;清理 DELETE /seed-fake 会原路退回。)
|
||||
rel.compare_reward_granted = True
|
||||
rel.compare_reward_cents = rewards.INVITE_COMPARE_REWARD_CENTS
|
||||
rel.compare_rewarded_at = now
|
||||
db.add(rel)
|
||||
db.flush()
|
||||
if is_compared:
|
||||
crud_wallet.grant_invite_cash(
|
||||
db, inviter_id, rewards.INVITE_COMPARE_REWARD_CENTS,
|
||||
biz_type="invite_reward", ref_id=str(u.id), remark="虚拟好友比价奖励(测试)",
|
||||
)
|
||||
created += 1
|
||||
db.commit()
|
||||
return created
|
||||
|
||||
|
||||
def clear_fake_invitees(db: Session, inviter_id: int) -> int:
|
||||
"""删掉该 inviter 名下所有 channel=fake_seed 的虚拟好友关系 + 对应虚拟 User(测试收尾)。
|
||||
|
||||
⚠️已发过邀请奖励金的假关系(compare_reward_granted),删除时必须把当初 grant 的钱**原路退回**
|
||||
(负数 grant_invite_cash),否则假好友清掉了、发进钱包的奖励金却残留 → invite_cash 余额脏账。
|
||||
"""
|
||||
rels = db.execute(
|
||||
select(InviteRelation).where(
|
||||
InviteRelation.inviter_user_id == inviter_id,
|
||||
InviteRelation.channel == "fake_seed",
|
||||
)
|
||||
).scalars().all()
|
||||
n = 0
|
||||
for rel in rels:
|
||||
# 先退钱:该关系当初发过奖励金 → 反向扣回同额,钱包不留残
|
||||
if rel.compare_reward_granted and rel.compare_reward_cents:
|
||||
crud_wallet.grant_invite_cash(
|
||||
db, inviter_id, -rel.compare_reward_cents,
|
||||
biz_type="invite_reward", ref_id=str(rel.invitee_user_id),
|
||||
remark="虚拟好友清理·奖励金退回(测试)",
|
||||
)
|
||||
u = db.get(User, rel.invitee_user_id)
|
||||
db.delete(rel)
|
||||
if u is not None and u.register_channel == "fake_seed":
|
||||
db.delete(u)
|
||||
n += 1
|
||||
db.commit()
|
||||
return n
|
||||
|
||||
|
||||
def dev_reset_test_account(db: Session, phone: str) -> dict:
|
||||
"""[DEV] 把固定测试号打回"从没被邀请/没下过单"的出厂态,以便反复当新用户测发奖。
|
||||
|
||||
清理范围(全清,冰 2026-07-02 拍板):该 user 的
|
||||
- invite_relation(作为被邀请人的那条 = 解 already_bound;作为邀请人的那些)
|
||||
- invite_cash_transaction / coin_account 三个余额归零(invite_cash / coin / cash)
|
||||
- savings_record(= 解 order/report 的 duplicated 幂等)
|
||||
- invite_fingerprint(作为邀请人落的指纹)
|
||||
- coin_transaction / cash_transaction / withdraw_order(彻底出厂)
|
||||
并把 user.created_at 刷成 now(UTC)—— 骗过 bind 的"新用户 72h 窗口"闸(_is_new_user),
|
||||
否则该号第一次跑之后 created_at 固定、过 72h 即使清了关系也会 not_eligible。
|
||||
|
||||
仅供 dev-reset 端点调用(端点已 is_prod→404 + 只认配置的测试号)。返回各表清理条数。
|
||||
user 不存在(还没登录过)→ 返回全 0,不报错(下一步登录会新建,天然新号)。
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.models.invite_fingerprint import InviteFingerprint
|
||||
from app.models.savings import SavingsRecord
|
||||
from app.models.wallet import (
|
||||
CashTransaction,
|
||||
CoinAccount,
|
||||
CoinTransaction,
|
||||
InviteCashTransaction,
|
||||
WithdrawOrder,
|
||||
)
|
||||
|
||||
user = db.execute(select(User).where(User.phone == phone)).scalar_one_or_none()
|
||||
if user is None:
|
||||
return {"user_found": False}
|
||||
uid = user.id
|
||||
|
||||
def _del(model, whereclause) -> int:
|
||||
rows = db.execute(select(model).where(whereclause)).scalars().all()
|
||||
for r in rows:
|
||||
db.delete(r)
|
||||
return len(rows)
|
||||
|
||||
cleared = {
|
||||
"user_found": True,
|
||||
"user_id": uid,
|
||||
"invite_relation_as_invitee": _del(
|
||||
InviteRelation, InviteRelation.invitee_user_id == uid
|
||||
),
|
||||
"invite_relation_as_inviter": _del(
|
||||
InviteRelation, InviteRelation.inviter_user_id == uid
|
||||
),
|
||||
"invite_cash_transaction": _del(
|
||||
InviteCashTransaction, InviteCashTransaction.user_id == uid
|
||||
),
|
||||
"coin_transaction": _del(CoinTransaction, CoinTransaction.user_id == uid),
|
||||
"cash_transaction": _del(CashTransaction, CashTransaction.user_id == uid),
|
||||
"withdraw_order": _del(WithdrawOrder, WithdrawOrder.user_id == uid),
|
||||
"invite_fingerprint": _del(
|
||||
InviteFingerprint, InviteFingerprint.inviter_user_id == uid
|
||||
),
|
||||
"savings_record": _del(SavingsRecord, SavingsRecord.user_id == uid),
|
||||
}
|
||||
|
||||
# 余额账户归零(保留行,只清三个余额;没有账户行则跳过)
|
||||
acc = db.get(CoinAccount, uid)
|
||||
if acc is not None:
|
||||
acc.invite_cash_balance_cents = 0
|
||||
acc.coin_balance = 0
|
||||
acc.cash_balance_cents = 0
|
||||
cleared["coin_account_zeroed"] = True
|
||||
else:
|
||||
cleared["coin_account_zeroed"] = False
|
||||
|
||||
# 刷 created_at 骗过 72h 新用户闸(存 naive UTC,与登录新建 user 的写法一致)
|
||||
user.created_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
cleared["created_at_refreshed"] = True
|
||||
|
||||
db.commit()
|
||||
return cleared
|
||||
|
||||
|
||||
# ===== 指纹归因(剪贴板兜底,见 [[invite-three-tasks]] 任务 3)=====
|
||||
|
||||
def record_fingerprint(
|
||||
|
||||
@@ -296,9 +296,6 @@ def daily_auto_exchange(db: Session) -> dict:
|
||||
- **逐用户独立事务**:单个用户异常 rollback 不影响其他人;exchange_coins_to_cash 内部按用户 commit。
|
||||
返回统计 dict(scanned/converted/skipped_done/skipped_dust/failed/total_cents)。
|
||||
"""
|
||||
# ⚠️「今天」写死北京时(rewards.cn_today() = datetime.now(CN_TZ).date(), CN_TZ=+8),与服务器/用户
|
||||
# 时区无关(用户 2026-07-01 硬约束:兑换一律北京 0 点日切)。**禁止**改成 date.today() / 裸
|
||||
# datetime.now().date()——那会跟随进程本地时区,服务器非 CST 时会在错误的"天"兑/重复兑。
|
||||
today = rewards.cn_today()
|
||||
stats = {
|
||||
"scanned": 0, "converted": 0, "skipped_done": 0,
|
||||
|
||||
@@ -49,28 +49,3 @@ class CouponStatsOut(BaseModel):
|
||||
"""
|
||||
|
||||
coupon_count: int
|
||||
|
||||
|
||||
class CouponSessionIn(BaseModel):
|
||||
"""客户端领券流水上报体(admin「领券数据」看板数据源,POST /api/v1/coupon/session)。
|
||||
|
||||
一次领券两段上报,按 trace_id upsert 到 coupon_session:
|
||||
- 发起(status=started):带勾选平台 + 机型/ROM/app_env + started_at_ms(发起墙钟毫秒)。
|
||||
- 收尾(completed/failed/abandoned):带 elapsed_ms(全程耗时)+ platform_elapsed(各平台耗时)+ claimed_count。
|
||||
不鉴权(同领券循环 MVP,按 device_id/trace_id),user_id 登录态带上做留痕(可空)。
|
||||
"""
|
||||
|
||||
trace_id: str
|
||||
device_id: str
|
||||
status: str # started / completed / failed / abandoned
|
||||
started_at_ms: int # 发起墙钟毫秒(客户端 System.currentTimeMillis)
|
||||
user_id: int | None = None
|
||||
platforms: list[str] | None = None
|
||||
origin_package: str | None = None
|
||||
device_model: str | None = None
|
||||
rom: str | None = None
|
||||
app_env: str | None = None
|
||||
elapsed_ms: int | None = None
|
||||
platform_elapsed: dict[str, int] | None = None
|
||||
claimed_count: int | None = None
|
||||
trace_url: str | None = None
|
||||
|
||||
@@ -69,7 +69,6 @@ class InviteeItem(BaseModel):
|
||||
avatar_url: str | None = None # 头像 URL;null = 前端画默认色块
|
||||
coins: int # 这次邀请给我(邀请人)发的金币
|
||||
invited_at: datetime # 邀请绑定时间(前端转"今天/3天前")
|
||||
is_compared: bool = False # 该好友是否已完成过一次比价(好友列表分"去提醒/邀请成功";在途列表只取 False)
|
||||
|
||||
|
||||
class InviteeListOut(BaseModel):
|
||||
|
||||
@@ -19,9 +19,6 @@ Type=oneshot
|
||||
User=root
|
||||
WorkingDirectory=/opt/shaguabijia-app-server
|
||||
Environment="PATH=/opt/shaguabijia-app-server/.venv/bin:/usr/bin:/bin"
|
||||
# 写死北京时:兑换的"当天/0 点"一律按北京时,不随服务器 OS 时区漂(用户 2026-07-01 硬约束)。
|
||||
# 脚本内的日期判断本就走 rewards.cn_today()(CN_TZ=+8),这里再把进程 TZ 也钉成北京,双保险。
|
||||
Environment="TZ=Asia/Shanghai"
|
||||
EnvironmentFile=/opt/shaguabijia-app-server/.env
|
||||
ExecStart=/opt/shaguabijia-app-server/.venv/bin/python -m scripts.daily_auto_exchange --once
|
||||
SyslogIdentifier=daily-exchange
|
||||
|
||||
@@ -4,10 +4,8 @@
|
||||
Description=Run daily auto-exchange coins->cash at midnight
|
||||
|
||||
[Timer]
|
||||
# 每天【北京时】0 点跑,写死时区(用户 2026-07-01 硬约束:兑换一律北京 0 点,不随服务器本地时区漂)。
|
||||
# systemd OnCalendar 支持尾缀时区;不写时区会按服务器 OS 本地时区触发 → 服务器非 CST 时会在错误时刻兑。
|
||||
# 客户端文案已注明「可能存在延迟」,可按需改 00:05 错开整点扎堆。
|
||||
OnCalendar=*-*-* 00:00:00 Asia/Shanghai
|
||||
# 每天 0 点跑。客户端文案已注明「可能存在延迟」,可按需改 00:05 错开整点扎堆。
|
||||
OnCalendar=*-*-* 00:00:00
|
||||
# 服务器宕机/重启后,补跑错过的那一轮(而不是干等次日)。
|
||||
Persistent=true
|
||||
AccuracySec=1min
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
# 邀请发奖真环境验证脚本(方案①:纯后端)—— PowerShell 版 ·【192 局域网 debug 环境】
|
||||
# ==============================================================================
|
||||
# 证明:好友 B 实际下单(POST /api/v1/order/report)→ 邀请人 A 到账 2 元(invite_cash),
|
||||
# 且「只绑定不下单不发」「重复不重发」「无关系不发」;并证明固定测试号 B 经
|
||||
# dev-reset 后可【反复】当新用户,再次触发发奖。
|
||||
# 不用真下单 / 不用付钱 / 不用无障碍 / 不用第二台设备。
|
||||
#
|
||||
# 打的是本地局域网 debug 环境(默认 http://192.168.0.144:8770 = 本机 LAN IP)。
|
||||
# 两个前提:① SMS_MOCK=true(默认) → 任意 6 位验证码通过;② .env 配了
|
||||
# TEST_ACCOUNT_PHONE=11111111111 并【重启过】app-server(B 靠它免验证码 + dev-reset)。
|
||||
#
|
||||
# 用法(PowerShell):
|
||||
# .\invite_reward_e2e.ps1 # 默认 192.168.0.144:8770
|
||||
# .\invite_reward_e2e.ps1 -BaseUrl http://127.0.0.1:8770
|
||||
# ==============================================================================
|
||||
param(
|
||||
# 目标环境:本地局域网 debug :8770(默认本机 LAN IP)
|
||||
[string]$BaseUrl = "http://192.168.0.144:8770"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$BaseUrl = $BaseUrl.TrimEnd('/')
|
||||
|
||||
# B 固定用测试号(免验证码 + 可 dev-reset 反复当新用户)。必须与 .env 的 TEST_ACCOUNT_PHONE 一致。
|
||||
$phoneB = "11111111111"
|
||||
# A(邀请人)/ X(无关系)用带时间戳的新号。
|
||||
$stamp = [int][double]::Parse((Get-Date -UFormat %s))
|
||||
$suffix = ($stamp % 100000000)
|
||||
$phoneX = "139" + ("{0:D8}" -f $suffix)
|
||||
|
||||
$expectCents = 200 # = INVITE_COMPARE_REWARD_CENTS(2 元)
|
||||
|
||||
Write-Host "======================================================================"
|
||||
Write-Host " 邀请发奖·192 局域网 debug 验证 BASE_URL=$BaseUrl"
|
||||
Write-Host " B(固定测试号·被邀请人)=$phoneB X(无关系·下单)=$phoneX"
|
||||
Write-Host "======================================================================"
|
||||
|
||||
# ---- HTTP 小工具(Invoke-RestMethod 自动解析 JSON)---------------------------
|
||||
function Invoke-Api {
|
||||
param([string]$Method, [string]$Path, $Body, [string]$Token)
|
||||
$headers = @{ "Content-Type" = "application/json; charset=utf-8" }
|
||||
if ($Token) { $headers["Authorization"] = "Bearer $Token" }
|
||||
$req = @{ Method = $Method; Uri = "$BaseUrl$Path"; Headers = $headers }
|
||||
if ($null -ne $Body) {
|
||||
# 用 UTF8 字节发,保证「美团」等中文不乱码
|
||||
$json = ($Body | ConvertTo-Json -Compress)
|
||||
$req["Body"] = [System.Text.Encoding]::UTF8.GetBytes($json)
|
||||
}
|
||||
return Invoke-RestMethod @req
|
||||
}
|
||||
|
||||
# 登录:发码(mock 不真发)→ 用 123456 登录 → 回 access_token
|
||||
# 唯一 device_id 避开「device_id+IP 每小时 5 次」限流(测试号 B 本不受限,统一带上无害)。
|
||||
function Login {
|
||||
param([string]$Phone)
|
||||
$dev = "e2e-$Phone-$stamp"
|
||||
Invoke-Api POST "/api/v1/auth/sms/send" @{ phone = $Phone; device_id = $dev } | Out-Null
|
||||
$r = Invoke-Api POST "/api/v1/auth/sms/login" @{ phone = $Phone; code = "123456"; device_id = $dev }
|
||||
if (-not $r.access_token) { throw "登录失败 phone=$Phone" }
|
||||
return $r.access_token
|
||||
}
|
||||
|
||||
# 把固定测试号 B 打回出厂态(清绑定/发奖/savings + 刷 created_at 骗过 72h 闸)。无需鉴权。
|
||||
function Reset-TestAccount {
|
||||
try {
|
||||
$r = Invoke-Api POST "/api/v1/invite/dev-reset" @{}
|
||||
} catch {
|
||||
throw "dev-reset 失败:$($_.Exception.Message) (检查 app-server 是否用最新代码重启?.env 是否配了 TEST_ACCOUNT_PHONE 并重启?)"
|
||||
}
|
||||
if ($r.status -ne "ok") { throw "dev-reset 返回非 ok:$($r | ConvertTo-Json -Compress)" }
|
||||
}
|
||||
|
||||
function Get-Reward { param([string]$Tok) (Invoke-Api GET "/api/v1/invite/me" $null $Tok).reward_balance_cents }
|
||||
function Get-MyCode { param([string]$Tok) (Invoke-Api GET "/api/v1/invite/me" $null $Tok).invite_code }
|
||||
function Bind-To { param([string]$Tok,[string]$Code) Invoke-Api POST "/api/v1/invite/bind" @{ invite_code = $Code } $Tok }
|
||||
function Report-Order {
|
||||
param([string]$Tok,[string]$EventId)
|
||||
Invoke-Api POST "/api/v1/order/report" @{
|
||||
client_event_id = $EventId
|
||||
platform = "美团"
|
||||
pay_channel = "wechat"
|
||||
compared_price_cents = 2016
|
||||
paid_amount_cents = 2016
|
||||
} $Tok | Out-Null
|
||||
}
|
||||
|
||||
# ---- 断言 -------------------------------------------------------------------
|
||||
$script:pass = 0; $script:fail = 0
|
||||
function Assert-Eq {
|
||||
param($Expected, $Actual, [string]$Desc)
|
||||
if ("$Expected" -eq "$Actual") { Write-Host " ✓ $Desc (=$Actual)"; $script:pass++ }
|
||||
else { Write-Host " ✗ $Desc 期望=$Expected 实得=$Actual" -ForegroundColor Red; $script:fail++ }
|
||||
}
|
||||
|
||||
# ---- 一轮完整发奖验证:reset B → 新 A 绑定 → B 下单 → A 到账 -----------------
|
||||
function Invoke-RewardCycle {
|
||||
param([int]$Tag)
|
||||
$phoneA = "138" + ("{0:D8}" -f (($suffix + $Tag) % 100000000))
|
||||
Write-Host "`n===== 第 $Tag 轮:A=$phoneA 邀请固定号 B=$phoneB ====="
|
||||
|
||||
Reset-TestAccount # 把 B 打回新用户态
|
||||
$tokA = Login $phoneA
|
||||
$tokB = Login $phoneB
|
||||
$codeA = Get-MyCode $tokA
|
||||
Write-Host " A 邀请码=$codeA;B 已 dev-reset 回新用户"
|
||||
|
||||
Assert-Eq 0 (Get-Reward $tokA) "[$Tag] 发奖前 A.invite_cash"
|
||||
|
||||
$bind = Bind-To $tokB $codeA
|
||||
Write-Host " bind → status=$($bind.status)(期望 success)"
|
||||
Assert-Eq 0 (Get-Reward $tokA) "[$Tag] 仅绑定后 A.invite_cash(绑定不发)"
|
||||
|
||||
Report-Order $tokB "evt-$stamp-$Tag-1"
|
||||
Assert-Eq $expectCents (Get-Reward $tokA) "[$Tag] B 首单后 A.invite_cash(下单才发)"
|
||||
|
||||
Report-Order $tokB "evt-$stamp-$Tag-1" # 重复同 event
|
||||
Report-Order $tokB "evt-$stamp-$Tag-2" # 不同 event 第二笔
|
||||
Assert-Eq $expectCents (Get-Reward $tokA) "[$Tag] B 重复/多次上报后 A.invite_cash(只发一次)"
|
||||
}
|
||||
|
||||
# ============================== 开跑 =========================================
|
||||
# 第 1、2 轮:同一个固定号 B 反复当新用户各拿一次发奖(证明 dev-reset 让它可反复用)。
|
||||
Invoke-RewardCycle 1
|
||||
Invoke-RewardCycle 2
|
||||
|
||||
# 无关系用户下单 → 不发奖
|
||||
Write-Host "`n===== 无关系校验:X 没绑定任何人,下单不应发奖 ====="
|
||||
$tokX = Login $phoneX
|
||||
Report-Order $tokX "evt-$stamp-x"
|
||||
Assert-Eq 0 (Get-Reward $tokX) "无邀请关系 X 下单后 X.invite_cash(不发)"
|
||||
|
||||
# ============================== 结果 =========================================
|
||||
Write-Host "`n======================================================================"
|
||||
if ($script:fail -eq 0) {
|
||||
Write-Host " ✅ 全部通过($script:pass 项):下单才发奖 / 绑定不发 / 重复不重发 / 无关系不发" -ForegroundColor Green
|
||||
Write-Host " 且固定号 B 经 dev-reset 后【反复】当新用户两轮均发奖成功。" -ForegroundColor Green
|
||||
exit 0
|
||||
} else {
|
||||
Write-Host " ❌ 有 $($script:fail) 项未通过(通过 $($script:pass) 项)。看上面 ✗ 行。" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# 邀请发奖真环境验证脚本(方案①:纯后端 curl)——【192 局域网 debug 包环境】
|
||||
# ------------------------------------------------------------------------------
|
||||
# 证明:好友 B 实际下单(POST /api/v1/order/report)→ 邀请人 A 到账 2 元(invite_cash),
|
||||
# 且「只绑定不下单不发」「重复上报不重发」「无邀请关系不发」;
|
||||
# 并证明固定测试号 B 经 dev-reset 后可【反复】当新用户,再次触发发奖。
|
||||
# 不用真下单 / 不用付钱 / 不用无障碍 / 不用第二台设备。
|
||||
#
|
||||
# 打的是本地局域网 debug 环境(默认 http://192.168.0.144:8770 = 本机 LAN IP)。
|
||||
# 两个前提(不满足脚本白跑):
|
||||
# ① SMS_MOCK=true(默认即是)→ /sms/login 放行任意 6 位验证码,故用 123456 拿 token。
|
||||
# ② .env 里配了 TEST_ACCOUNT_PHONE=11111111111 并【重启过】app-server —— B 用它免验证码
|
||||
# 登录,且脚本靠 POST /invite/dev-reset 每轮把它打回新用户态。
|
||||
#
|
||||
# 用法:
|
||||
# ./invite_reward_e2e.sh # 用默认 192.168.0.144:8770
|
||||
# ./invite_reward_e2e.sh http://192.168.0.144:8770 # 位置参数传 base
|
||||
# BASE_URL=http://127.0.0.1:8770 ./invite_reward_e2e.sh
|
||||
# 依赖:仅 curl(JSON 用 grep/sed 抽字段,不依赖 python/jq —— Windows Git Bash 里
|
||||
# python3 常是 WindowsApps 假 stub,故意不用)。
|
||||
# ==============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# ---- 目标环境:本地局域网 debug :8770(改成你的开发机内网 IP)------------------
|
||||
BASE_URL="${1:-${BASE_URL:-http://192.168.0.144:8770}}"
|
||||
BASE_URL="${BASE_URL%/}" # 去掉结尾多余的 /
|
||||
|
||||
# B 固定用测试号(免验证码 + 可 dev-reset 反复当新用户)。必须与 .env 的 TEST_ACCOUNT_PHONE 一致。
|
||||
PHONE_B="11111111111"
|
||||
|
||||
# A(邀请人)/ X(无关系)每轮用带时间戳的新号:A 需从 0 余额验起、X 只要没关系即可。
|
||||
STAMP="$(date +%s)"
|
||||
SUFFIX="${STAMP: -8}"
|
||||
PHONE_X="139${SUFFIX}"
|
||||
|
||||
EXPECT_CENTS=200 # = INVITE_COMPARE_REWARD_CENTS(2 元)
|
||||
|
||||
echo "======================================================================"
|
||||
echo " 邀请发奖·192 局域网 debug 验证 BASE_URL=$BASE_URL"
|
||||
echo " B(固定测试号·被邀请人)=$PHONE_B X(无关系·下单)=$PHONE_X"
|
||||
echo "======================================================================"
|
||||
|
||||
# ---- 小工具:从 JSON 取字段(纯 grep/sed,不依赖 python/jq)------------------
|
||||
# 用法: echo "$json" | jget access_token
|
||||
# 支持字符串值("key":"val")与数字值("key":123);只取顶层首个匹配,够本脚本用。
|
||||
jget() {
|
||||
local key="$1" body; body="$(cat)"
|
||||
local v
|
||||
v="$(printf '%s' "$body" | grep -o "\"$key\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -1 | sed -E "s/.*:[[:space:]]*\"([^\"]*)\"/\1/")"
|
||||
if [ -n "$v" ]; then printf '%s' "$v"; return; fi
|
||||
printf '%s' "$body" | grep -o "\"$key\"[[:space:]]*:[[:space:]]*-\?[0-9]\+" | head -1 | sed -E "s/.*:[[:space:]]*(-?[0-9]+)/\1/"
|
||||
}
|
||||
|
||||
# ---- POST JSON:body 走 stdin + --data-binary,不内联 -d --------------------
|
||||
# ⚠️ Windows Git Bash 里内联 -d "{...美团...}" 会把中文 UTF-8 字节搞乱 → 后端
|
||||
# "error parsing the body"。把 body 从 stdin 以原始字节喂进去可避开。
|
||||
post_json() {
|
||||
local path="$1" token="$2"
|
||||
local auth=()
|
||||
[ -n "$token" ] && auth=(-H "Authorization: Bearer $token")
|
||||
curl -sS -X POST "$BASE_URL$path" \
|
||||
-H 'Content-Type: application/json' "${auth[@]}" \
|
||||
--data-binary @-
|
||||
}
|
||||
|
||||
# ---- 登录:发码(mock 不真发)→ 用 123456 登录 → 回 access_token -----------
|
||||
# 每个号带唯一 device_id(=phone+stamp):登录/发码限流是「device_id+IP 每小时 5 次」,
|
||||
# 空 device_id 会让本机所有请求挤同一桶,自己把自己限流掉。给唯一 id 各走各的桶。
|
||||
# (测试号 B 本就不受该限流约束,但带上 device_id 无害、统一处理。)
|
||||
login() {
|
||||
local phone="$1"
|
||||
local dev="e2e-${phone}-${STAMP}"
|
||||
post_json /api/v1/auth/sms/send "" <<< "{\"phone\":\"$phone\",\"device_id\":\"$dev\"}" >/dev/null
|
||||
local resp
|
||||
resp="$(post_json /api/v1/auth/sms/login "" <<< "{\"phone\":\"$phone\",\"code\":\"123456\",\"device_id\":\"$dev\"}")"
|
||||
local tok; tok="$(printf '%s' "$resp" | jget access_token)"
|
||||
if [ -z "$tok" ]; then
|
||||
echo "✗ 登录失败 phone=$phone,响应:$resp" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "$tok"
|
||||
}
|
||||
|
||||
# 把固定测试号 B 打回出厂态(清绑定/发奖/savings + 刷 created_at 骗过 72h 闸)。无需鉴权。
|
||||
dev_reset() {
|
||||
local resp; resp="$(post_json /api/v1/invite/dev-reset "" <<< '{}')"
|
||||
if [ "$(printf '%s' "$resp" | jget status)" != "ok" ]; then
|
||||
echo "✗ dev-reset 失败,响应:$resp" >&2
|
||||
echo " (检查:app-server 是否已用最新代码重启?.env 是否配了 TEST_ACCOUNT_PHONE 并重启?)" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# GET /invite/me 的某个字段(邀请码 / 奖励金余额都从这里读)
|
||||
invite_me() { curl -sS "$BASE_URL/api/v1/invite/me" -H "Authorization: Bearer $1"; }
|
||||
my_code() { invite_me "$1" | jget invite_code; }
|
||||
reward() { invite_me "$1" | jget reward_balance_cents; }
|
||||
|
||||
bind_to() { post_json /api/v1/invite/bind "$1" <<< "{\"invite_code\":\"$2\"}"; }
|
||||
report_order() {
|
||||
post_json /api/v1/order/report "$1" <<< "{\"client_event_id\":\"$2\",\"platform\":\"美团\",\"pay_channel\":\"wechat\",\"compared_price_cents\":2016,\"paid_amount_cents\":2016}"
|
||||
}
|
||||
|
||||
# ---- 断言 -------------------------------------------------------------------
|
||||
PASS=0; FAIL=0
|
||||
assert_eq() { # 期望 实得 说明
|
||||
if [ "$1" = "$2" ]; then echo " ✓ $3 (=$2)"; PASS=$((PASS+1));
|
||||
else echo " ✗ $3 期望=$1 实得=$2"; FAIL=$((FAIL+1)); fi
|
||||
}
|
||||
|
||||
# ---- 一轮完整发奖验证:reset B → 新 A 绑定 → B 下单 → A 到账 -----------------
|
||||
# $1=轮次标签(用于 event_id 唯一 + 展示)。A 每轮用不同新号(从 0 验起)。
|
||||
run_reward_cycle() {
|
||||
local tag="$1"
|
||||
local phone_a="138$(printf '%08d' $(( (10#$SUFFIX + tag) % 100000000 )) )"
|
||||
echo; echo "===== 第 $tag 轮:A=$phone_a 邀请固定号 B=$PHONE_B ====="
|
||||
|
||||
dev_reset # 把 B 打回新用户态
|
||||
local tok_a tok_b code_a
|
||||
tok_a="$(login "$phone_a")"
|
||||
tok_b="$(login "$PHONE_B")"
|
||||
code_a="$(my_code "$tok_a")"
|
||||
echo " A 邀请码=$code_a;B 已 dev-reset 回新用户"
|
||||
|
||||
assert_eq 0 "$(reward "$tok_a")" "[$tag] 发奖前 A.invite_cash"
|
||||
|
||||
local bind_resp; bind_resp="$(bind_to "$tok_b" "$code_a")"
|
||||
echo " bind → status=$(printf '%s' "$bind_resp" | jget status)(期望 success)"
|
||||
assert_eq 0 "$(reward "$tok_a")" "[$tag] 仅绑定后 A.invite_cash(绑定不发)"
|
||||
|
||||
report_order "$tok_b" "evt-$STAMP-$tag-1" >/dev/null
|
||||
assert_eq "$EXPECT_CENTS" "$(reward "$tok_a")" "[$tag] B 首单后 A.invite_cash(下单才发)"
|
||||
|
||||
report_order "$tok_b" "evt-$STAMP-$tag-1" >/dev/null # 重复同 event
|
||||
report_order "$tok_b" "evt-$STAMP-$tag-2" >/dev/null # 不同 event 的第二笔
|
||||
assert_eq "$EXPECT_CENTS" "$(reward "$tok_a")" "[$tag] B 重复/多次上报后 A.invite_cash(只发一次)"
|
||||
}
|
||||
|
||||
# ============================== 开跑 =========================================
|
||||
# 第 1、2 轮:同一个固定号 B 反复当新用户各拿一次发奖(证明 dev-reset 让它可反复用)。
|
||||
run_reward_cycle 1
|
||||
run_reward_cycle 2
|
||||
|
||||
# 无关系用户下单 → 不发奖
|
||||
echo; echo "===== 无关系校验:X 没绑定任何人,下单不应发奖 ====="
|
||||
TOK_X="$(login "$PHONE_X")"
|
||||
report_order "$TOK_X" "evt-$STAMP-x" >/dev/null
|
||||
assert_eq 0 "$(reward "$TOK_X")" "无邀请关系 X 下单后 X.invite_cash(不发)"
|
||||
|
||||
# ============================== 结果 =========================================
|
||||
echo
|
||||
echo "======================================================================"
|
||||
if [ "$FAIL" -eq 0 ]; then
|
||||
echo " ✅ 全部通过($PASS 项):下单才发奖 / 绑定不发 / 重复不重发 / 无关系不发"
|
||||
echo " 且固定号 B 经 dev-reset 后【反复】当新用户两轮均发奖成功。"
|
||||
exit 0
|
||||
else
|
||||
echo " ❌ 有 $FAIL 项未通过(通过 $PASS 项)。看上面 ✗ 行。"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,82 +0,0 @@
|
||||
"""金币→现金「每日自动兑」时区回归测试。
|
||||
|
||||
用户 2026-07-01 硬约束:不管服务器 / 用户设成什么时区,兑换一律按【北京时 0 点】日切。
|
||||
本测试锁死这个行为,防以后有人把 rewards.cn_today() 改回 date.today() / 裸 datetime.now()
|
||||
(那会跟随进程本地时区,服务器非 CST 时在错误的"天"兑或重复兑)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.core import rewards
|
||||
from app.core.rewards import COIN_PER_CENT
|
||||
from app.db.session import SessionLocal
|
||||
from app.repositories import wallet as crud_wallet
|
||||
from app.repositories.user import get_user_by_phone
|
||||
|
||||
|
||||
def _login(client, phone: str) -> str:
|
||||
client.post("/api/v1/auth/sms/send", json={"phone": phone})
|
||||
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def test_cn_today_is_beijing_date_regardless_of_utc_offset() -> None:
|
||||
"""cn_today() 恒等于北京时的日期,与 UTC / 进程本地时区无关。
|
||||
|
||||
取一个 UTC 与北京跨天的时刻(北京已是次日 0 点后、UTC 还在前一天)验证:
|
||||
2026-07-01 15:30 UTC = 2026-07-01 23:30 北京(同一天);
|
||||
2026-07-01 17:30 UTC = 2026-07-02 01:30 北京(北京已跨天)。
|
||||
"""
|
||||
cst = timezone(rewards.CN_TZ.utcoffset(None))
|
||||
# UTC 17:30 → 北京次日 01:30:北京日期应是 07-02
|
||||
moment_utc = datetime(2026, 7, 1, 17, 30, tzinfo=timezone.utc)
|
||||
assert moment_utc.astimezone(cst).date().isoformat() == "2026-07-02"
|
||||
# 反过来 UTC 23:30 → 北京 07:30 次日:北京日期 07-02(证明 +8 偏移生效)
|
||||
moment_utc2 = datetime(2026, 7, 1, 23, 30, tzinfo=timezone.utc)
|
||||
assert moment_utc2.astimezone(cst).date().isoformat() == "2026-07-02"
|
||||
# cn_today() 的实现就是 datetime.now(CN_TZ).date():与 UTC now 的北京投影一致
|
||||
assert rewards.cn_today() == datetime.now(timezone.utc).astimezone(cst).date()
|
||||
|
||||
|
||||
def test_daily_auto_exchange_idempotent_within_same_beijing_day(client) -> None:
|
||||
"""同一北京日内多次触发只兑一次(幂等键 = 当天是否已有 exchange_in 北京日流水)。"""
|
||||
token = _login(client, "13800007701")
|
||||
assert token
|
||||
with SessionLocal() as db:
|
||||
user = get_user_by_phone(db, "13800007701")
|
||||
assert user is not None
|
||||
# 给 200 金币(= 2 分),够到分兑换
|
||||
crud_wallet.grant_coins(db, user.id, 200, biz_type="test_grant")
|
||||
db.commit()
|
||||
|
||||
first = crud_wallet.daily_auto_exchange(db)
|
||||
assert first["converted"] == 1, first
|
||||
assert first["total_cents"] == 2, first
|
||||
|
||||
# 当天再进账 200 金币,再触发一轮:因当天已有 exchange_in(北京日幂等键)→ 跳过,
|
||||
# 不重复兑(这 200 留到次日北京 0 点)。若幂等键被改成本地时区,服务器非 CST 时这里会误兑。
|
||||
crud_wallet.grant_coins(db, user.id, 200, biz_type="test_grant")
|
||||
db.commit()
|
||||
second = crud_wallet.daily_auto_exchange(db)
|
||||
assert second["converted"] == 0, second
|
||||
assert second["skipped_done"] >= 1, second
|
||||
|
||||
|
||||
def test_has_exchange_in_on_keyed_on_given_beijing_day(client) -> None:
|
||||
"""_has_exchange_in_on 以传入的北京日为界比较 [当天0点, 次日0点)。"""
|
||||
token = _login(client, "13800007702")
|
||||
assert token
|
||||
with SessionLocal() as db:
|
||||
user = get_user_by_phone(db, "13800007702")
|
||||
assert user is not None
|
||||
crud_wallet.grant_coins(db, user.id, 200, biz_type="test_grant")
|
||||
db.commit()
|
||||
crud_wallet.daily_auto_exchange(db)
|
||||
|
||||
today = rewards.cn_today()
|
||||
assert crud_wallet._has_exchange_in_on(db, user.id, today) is True
|
||||
# 昨天(北京日)不该命中今天兑的流水
|
||||
yesterday = today.fromordinal(today.toordinal() - 1)
|
||||
assert crud_wallet._has_exchange_in_on(db, user.id, yesterday) is False
|
||||
@@ -73,11 +73,9 @@ def test_bind_flow_no_coins_either_side(client) -> None:
|
||||
assert _coin_balance(client, b) == 0
|
||||
assert _coin_balance(client, a) == 0
|
||||
|
||||
# A 的战绩:此时 invited_count=0 —— 口径 = "完成过一次比价的好友数"(compare_reward_granted=True),
|
||||
# 仅绑定未比价不计入(否则会出现"已邀 1、可提现余额 0",与产品口径"已邀×2=累计提现+余额"对不上;
|
||||
# 见 get_stats 注释 + 需求 spec"邀请好友人数=邀请的完成一次比价的新用户数量")。金币口径收益恒 0。
|
||||
# A 的战绩:已邀 1 人;金币口径收益恒 0(邀请人收益走邀请奖励金,见 try_reward_on_compare)
|
||||
r = client.get("/api/v1/invite/me", headers=_auth(a))
|
||||
assert r.json()["invited_count"] == 0
|
||||
assert r.json()["invited_count"] == 1
|
||||
assert r.json()["coins_earned"] == 0
|
||||
|
||||
|
||||
@@ -324,8 +322,6 @@ def test_invitees_basic(client) -> None:
|
||||
assert all(it["avatar_url"] is None for it in body["items"])
|
||||
# v2:绑定时邀请人那份为 0(收益改"好友比价才发奖"),故每条 coins 字段=0
|
||||
assert all(it["coins"] == 0 for it in body["items"])
|
||||
# 刚绑定还没比价 → is_compared 全 False(客户端据此:在途列表取这些、好友列表按钮"去提醒")
|
||||
assert all(it["is_compared"] is False for it in body["items"])
|
||||
|
||||
|
||||
def test_invitees_order_desc(client) -> None:
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
"""邀请 v3 发奖测试:好友完成比价【并实际下单】→ 邀请人得邀请奖励金(独立账户),幂等只发一次,
|
||||
"""邀请 v3 比价发奖测试:好友完成成功比价 → 邀请人得邀请奖励金(独立账户),幂等只发一次,
|
||||
账户隔离不串金币/现金。被邀请人绑定不得任何奖励由 test_invite.py 覆盖。
|
||||
|
||||
⚠️ 触发口径(冰拍板):被邀请人仅完成比价【不】发奖,须实际下单(客户端归因后 POST
|
||||
/api/v1/order/report)才发。故本文件用下单上报驱动发奖,而非比价上报。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -33,17 +30,10 @@ def _bind(client, token: str, code: str):
|
||||
return client.post("/api/v1/invite/bind", json={"invite_code": code}, headers=_auth(token))
|
||||
|
||||
|
||||
def _report_order(client, token: str, event_id: str):
|
||||
"""上报一笔归因订单(视为实际下单)→ 触发邀请发奖。event_id 为幂等键。"""
|
||||
def _report_compare(client, token: str, trace_id: str, status: str = "success"):
|
||||
return client.post(
|
||||
"/api/v1/order/report",
|
||||
json={
|
||||
"client_event_id": event_id,
|
||||
"platform": "美团",
|
||||
"pay_channel": "wechat",
|
||||
"compared_price_cents": 2016,
|
||||
"paid_amount_cents": 2016,
|
||||
},
|
||||
"/api/v1/compare/record",
|
||||
json={"trace_id": trace_id, "status": status},
|
||||
headers=_auth(token),
|
||||
)
|
||||
|
||||
@@ -56,80 +46,58 @@ def _invite_cash(phone: str) -> int:
|
||||
return acc.invite_cash_balance_cents if acc else 0
|
||||
|
||||
|
||||
def test_order_reward_granted_to_inviter(client) -> None:
|
||||
"""B 被 A 邀请后实际下单一笔 → A 得邀请奖励金 2 元(独立账户),B 不得该奖。"""
|
||||
def test_compare_reward_granted_to_inviter(client) -> None:
|
||||
"""B 被 A 邀请后完成一次成功比价 → A 得邀请奖励金 2 元(独立账户),B 不得该奖。"""
|
||||
a = _login(client, "13800003001")
|
||||
b = _login(client, "13800003002")
|
||||
_bind(client, b, _my_code(client, a))
|
||||
|
||||
assert _invite_cash("13800003001") == 0 # 发奖前
|
||||
|
||||
r = _report_order(client, b, "evt-reward-1")
|
||||
r = _report_compare(client, b, "trace-reward-1")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
assert _invite_cash("13800003001") == INVITE_COMPARE_REWARD_CENTS # 邀请人到账
|
||||
assert _invite_cash("13800003002") == 0 # 被邀请人不得此奖
|
||||
|
||||
|
||||
def test_order_reward_idempotent(client) -> None:
|
||||
"""好友下单多次 → 只发一次(compare_reward_granted 幂等)。"""
|
||||
def test_compare_reward_idempotent(client) -> None:
|
||||
"""好友比价多次 → 只发一次(compare_reward_granted 幂等)。"""
|
||||
a = _login(client, "13800003003")
|
||||
b = _login(client, "13800003004")
|
||||
_bind(client, b, _my_code(client, a))
|
||||
|
||||
_report_order(client, b, "evt-idem-1")
|
||||
_report_order(client, b, "evt-idem-2") # 第二笔下单(不同 event)
|
||||
_report_order(client, b, "evt-idem-1") # 重复上报同 event
|
||||
_report_compare(client, b, "trace-idem-1")
|
||||
_report_compare(client, b, "trace-idem-2") # 第二次比价(不同 trace)
|
||||
_report_compare(client, b, "trace-idem-1") # 重复上报同 trace
|
||||
|
||||
assert _invite_cash("13800003003") == INVITE_COMPARE_REWARD_CENTS # 仍只发一次
|
||||
|
||||
|
||||
def test_invitees_is_compared_flag_flips_after_order(client) -> None:
|
||||
"""好友列表 is_compared:好友下单前 False(在途/去提醒)、下单后 True(邀请成功)。"""
|
||||
a = _login(client, "13800003021")
|
||||
b = _login(client, "13800003022")
|
||||
_bind(client, b, _my_code(client, a))
|
||||
with SessionLocal() as db:
|
||||
b_name = get_user_by_phone(db, "13800003022").nickname
|
||||
|
||||
def _b_item():
|
||||
items = client.get("/api/v1/invite/invitees", headers=_auth(a)).json()["items"]
|
||||
return next(it for it in items if it["display_name"] == b_name)
|
||||
|
||||
assert _b_item()["is_compared"] is False # 下单前:未完成(在途 / 去提醒)
|
||||
_report_order(client, b, "evt-flag-1") # B 实际下单一笔
|
||||
assert _b_item()["is_compared"] is True # 下单后:已完成(邀请成功)
|
||||
|
||||
|
||||
def test_order_no_relation_no_reward(client) -> None:
|
||||
"""没有邀请关系的人下单 → 不发奖(没人是他的邀请人)。"""
|
||||
def test_compare_no_relation_no_reward(client) -> None:
|
||||
"""没有邀请关系的人比价 → 不发奖(没人是他的邀请人)。"""
|
||||
x = _login(client, "13800003005")
|
||||
r = _report_order(client, x, "evt-norel-1")
|
||||
r = _report_compare(client, x, "trace-norel-1")
|
||||
assert r.status_code == 200, r.text
|
||||
assert _invite_cash("13800003005") == 0
|
||||
|
||||
|
||||
def test_compare_alone_no_reward(client) -> None:
|
||||
"""仅完成比价、未下单 → 不触发发奖(新口径:比价不再发奖)。"""
|
||||
def test_compare_failed_no_reward(client) -> None:
|
||||
"""失败的比价(status=failed)不触发发奖。"""
|
||||
a = _login(client, "13800003006")
|
||||
b = _login(client, "13800003007")
|
||||
_bind(client, b, _my_code(client, a))
|
||||
|
||||
r = client.post(
|
||||
"/api/v1/compare/record",
|
||||
json={"trace_id": "trace-noorder-1", "status": "success"},
|
||||
headers=_auth(b),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert _invite_cash("13800003006") == 0 # 只比价没下单:不发奖
|
||||
_report_compare(client, b, "trace-fail-1", status="failed")
|
||||
assert _invite_cash("13800003006") == 0
|
||||
|
||||
|
||||
def test_order_reward_isolated_from_coin_cash(client) -> None:
|
||||
def test_compare_reward_isolated_from_coin_cash(client) -> None:
|
||||
"""账户隔离:邀请奖励金进 invite_cash,不串 cash_balance_cents;流水落 invite_cash_transaction。"""
|
||||
a = _login(client, "13800003008")
|
||||
b = _login(client, "13800003009")
|
||||
_bind(client, b, _my_code(client, a))
|
||||
_report_order(client, b, "evt-iso-1")
|
||||
_report_compare(client, b, "trace-iso-1")
|
||||
|
||||
with SessionLocal() as db:
|
||||
ua = get_user_by_phone(db, "13800003008")
|
||||
@@ -144,48 +112,3 @@ def test_order_reward_isolated_from_coin_cash(client) -> None:
|
||||
).scalars().all()
|
||||
assert len(txns) == 1
|
||||
assert txns[0].amount_cents == INVITE_COMPARE_REWARD_CENTS
|
||||
|
||||
|
||||
def test_dev_reset_makes_test_account_fresh_again(client, monkeypatch) -> None:
|
||||
"""dev-reset 让固定测试号 B 可【反复】当新用户:发奖 → reset → 换个邀请人再发奖成功。
|
||||
|
||||
验证 POST /api/v1/invite/dev-reset(方案 B)把 B 的 invite_relation / invite_cash /
|
||||
savings 清掉并刷 created_at,于是它能再次绑定 + 下单触发第二次发奖(第一次的 already_bound /
|
||||
savings 幂等 / 72h 闸都被 reset 解除)。
|
||||
"""
|
||||
from app.core.config import settings
|
||||
|
||||
test_phone = "11111111111"
|
||||
# 端点按 settings.test_account_phone 认号 → 用 monkeypatch 在本用例内启用该测试号
|
||||
monkeypatch.setattr(settings, "TEST_ACCOUNT_PHONE", test_phone)
|
||||
|
||||
a1 = _login(client, "13800003101")
|
||||
b = _login(client, test_phone)
|
||||
|
||||
# 第一轮:B 绑 A1 → 下单 → A1 得奖
|
||||
assert _bind(client, b, _my_code(client, a1)).json()["status"] == "success"
|
||||
_report_order(client, b, "evt-reset-r1")
|
||||
assert _invite_cash("13800003101") == INVITE_COMPARE_REWARD_CENTS
|
||||
assert _invite_cash(test_phone) == 0 # 被邀请人 B 不得此奖
|
||||
|
||||
# dev-reset:把 B 打回出厂态(无需鉴权)
|
||||
r = client.post("/api/v1/invite/dev-reset")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["status"] == "ok" and body["phone"] == test_phone
|
||||
assert body["cleared"]["invite_relation_as_invitee"] == 1 # 上一轮那条被邀请关系被清
|
||||
|
||||
# 第二轮:同一个 B 绑【另一个】邀请人 A2 → 下单 → A2 也得奖(证明 B 又是新用户了)
|
||||
a2 = _login(client, "13800003102")
|
||||
assert _bind(client, b, _my_code(client, a2)).json()["status"] == "success" # 不再 already_bound
|
||||
_report_order(client, b, "evt-reset-r2")
|
||||
assert _invite_cash("13800003102") == INVITE_COMPARE_REWARD_CENTS # 第二个邀请人到账
|
||||
|
||||
|
||||
def test_dev_reset_blocked_when_test_account_disabled(client, monkeypatch) -> None:
|
||||
"""测试号总开关没配(TEST_ACCOUNT_PHONE 空)→ dev-reset 返回 400(功能未启用)。"""
|
||||
from app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "TEST_ACCOUNT_PHONE", "")
|
||||
r = client.post("/api/v1/invite/dev-reset")
|
||||
assert r.status_code == 400, r.text
|
||||
|
||||
Reference in New Issue
Block a user