78d970f420
- 新表 coupon_session(一次领券一行,trace_id 唯一):POST /api/v1/coupon/session 两段 upsert(发起 started / 收尾 completed·failed·abandoned),记全程耗时、各平台耗时、 机型/ROM、app_env、trace_url、origin_package(发起来源)。 - admin GET /admin/api/coupon-data:发起/完成数 + 平均耗时 + P5/P50/P95/P99(Python 算分位, SQLite 无 percentile)+ 按天/小时趋势 + 明细分页 + join 用户手机号/昵称,app_env 默认 prod; 另加 GET /admin/api/coupon-data/user-records 供「点手机号看该用户全部领券」抽屉。 - 迁移拆 3 个:建表 coupon_session_table + trace_url 加列 + origin_package 加列 (建表迁移已被某环境应用后改它不重跑,故新列单独加列迁移)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: #99 Co-authored-by: zhuzihao <zhuzihao@wonderable.ai> Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
314 lines
13 KiB
Python
314 lines
13 KiB
Python
"""领券业务透传端点。
|
|
|
|
把客户端的 POST /api/v1/coupon/step 请求原样转发给 pricebot-backend
|
|
的 /api/coupon/step。MVP 阶段**不鉴权**(见 docs/待办与技术债.md P1:
|
|
device_id 透传给 pricebot 区分设备,待补 JWT + device_id↔user_id 绑定后
|
|
才能做用户级画像)。
|
|
|
|
pricebot 协议文档:
|
|
pricebot-backend/docs/projects/领券-客户端对接协议.md
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, HTTPException, Request, status
|
|
from fastapi.concurrency import run_in_threadpool
|
|
|
|
from app.api.deps import CurrentUser, DbSession
|
|
from app.core.config import settings
|
|
from app.core.pricebot_client import get_pricebot_client
|
|
from app.core.pricebot_router import pick_pricebot
|
|
from app.db.session import SessionLocal
|
|
from app.repositories import coupon_state as coupon_repo
|
|
from app.schemas.coupon_state import (
|
|
CouponCompletedTodayOut,
|
|
CouponPromptDismissIn,
|
|
CouponPromptShouldShowOut,
|
|
CouponPromptShownIn,
|
|
CouponSessionIn,
|
|
CouponStatsOut,
|
|
)
|
|
|
|
logger = logging.getLogger("shagua.coupon")
|
|
|
|
router = APIRouter(prefix="/api/v1/coupon", tags=["coupon"])
|
|
|
|
|
|
def _to_int(v: object) -> int | None:
|
|
"""user_id 协议是字符串(登录态才带),转 int 存库;缺失/非法 → None。"""
|
|
if v is None:
|
|
return None
|
|
try:
|
|
return int(v) # type: ignore[arg-type]
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _extract_coupon_results(resp_json: dict) -> list[dict]:
|
|
"""从 pricebot 响应抽本帧券结果,并**按 coupon_id 去重**。
|
|
|
|
⚠️ done/单券帧同时带 last_coupon_result(最后一张)+ action.params.coupon_results
|
|
(全量含最后一张)→ 最后一张出现两次。必须去重:同批里同 coupon_id 两次,
|
|
record_claims 在 autoflush=False 下两次 select 都查不到刚 add 的行 → 重复 add →
|
|
commit 撞唯一约束 IntegrityError 回滚整批(done/单券记录全丢)。
|
|
coupon_results(全量权威)覆盖 last_coupon_result。"""
|
|
by_id: dict[str, dict] = {}
|
|
last = resp_json.get("last_coupon_result")
|
|
if isinstance(last, dict) and last.get("coupon_id"):
|
|
by_id[last["coupon_id"]] = last
|
|
params = (resp_json.get("action") or {}).get("params") or {}
|
|
cr = params.get("coupon_results")
|
|
if isinstance(cr, list):
|
|
for x in cr:
|
|
if isinstance(x, dict) and x.get("coupon_id"):
|
|
by_id[x["coupon_id"]] = x
|
|
return list(by_id.values())
|
|
|
|
|
|
def _mark_engagement_blocking(
|
|
device_id: str, package: str, user_id: int | None, engage_type: str
|
|
) -> None:
|
|
"""独立 session 写 engagement(async 端点经 run_in_threadpool 调,不阻塞事件循环)。"""
|
|
with SessionLocal() as db:
|
|
coupon_repo.mark_engagement(db, device_id, package, user_id, engage_type)
|
|
|
|
|
|
def _record_claims_blocking(
|
|
device_id: str, user_id: int | None, trace_id: str | None, results: list[dict]
|
|
) -> None:
|
|
with SessionLocal() as db:
|
|
coupon_repo.record_claims(db, device_id, user_id, trace_id, results)
|
|
|
|
|
|
def _mark_completed_blocking(
|
|
device_id: str, user_id: int | None, trace_id: str | None
|
|
) -> None:
|
|
"""独立 session 写"今日已跑完整轮"(到 done 帧那刻调,best-effort 不阻塞领券)。"""
|
|
with SessionLocal() as db:
|
|
coupon_repo.mark_completed_today(db, device_id, user_id, trace_id)
|
|
|
|
|
|
@router.post("/step", summary="领券任务步进 (透传到 pricebot)")
|
|
async def coupon_step(
|
|
request: Request,
|
|
) -> dict[str, Any]:
|
|
"""把请求体原样转发给 pricebot-backend 的 /api/coupon/step。
|
|
|
|
- 鉴权: MVP 阶段不鉴权(待补 JWT,见 docs/待办与技术债.md P1)
|
|
- 透传: 不做 schema 校验,pricebot 自己校验
|
|
- 失败: 网络不可达 / pricebot 5xx → 502 + 友好 message
|
|
"""
|
|
# 读原始字节,避免"反序列化→再序列化"的双重 JSON(省透传 CPU)。只 loads 一次拿
|
|
# trace_id 做亲和 + 打日志,转发时直接发原始 bytes,不重新 dumps。
|
|
raw = await request.body()
|
|
try:
|
|
meta = json.loads(raw)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=f"invalid json body: {e}") from e
|
|
if not isinstance(meta, dict):
|
|
meta = {}
|
|
|
|
device_id = meta.get("device_id")
|
|
user_id = _to_int(meta.get("user_id")) # 登录态才带;判断不靠它,资产留痕用
|
|
trace_id = meta.get("trace_id")
|
|
# 发起领券时前台 App 包名(step body 带 "package")。频控按 App,这条 engagement 要记到
|
|
# 对应 App 上。App 内「去领取」发起时 package 可能缺/为空 → 退化为 "" 占位(全局态)。
|
|
pkg = meta.get("package") or ""
|
|
|
|
# 领券任务首帧(step=0)= 用户已发起领券 → 记一条今日 engagement(claim_started),
|
|
# 今天**这个 App** 不再弹引导窗(对齐前台"点一键领取即 markEngaged")。写库失败绝不能
|
|
# 连累领券主流程,整段吞掉。
|
|
if device_id and meta.get("step") == 0:
|
|
try:
|
|
await run_in_threadpool(
|
|
_mark_engagement_blocking, device_id, pkg, user_id, "claim_started"
|
|
)
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning("coupon engagement write failed: %s", e)
|
|
|
|
# 按 trace_id 一致性 hash 选 pricebot 实例(同一领券任务的所有帧落同一进程)
|
|
base = pick_pricebot(meta.get("trace_id"))
|
|
url = f"{base.rstrip('/')}/api/coupon/step"
|
|
timeout = settings.PRICEBOT_REQUEST_TIMEOUT_SEC
|
|
|
|
logger.info(
|
|
"coupon_step device_id=%s trace_id=%s step=%s",
|
|
meta.get("device_id"),
|
|
meta.get("trace_id"),
|
|
meta.get("step"),
|
|
)
|
|
|
|
try:
|
|
client = get_pricebot_client()
|
|
resp = await client.post(
|
|
url, content=raw, headers={"Content-Type": "application/json"}, timeout=timeout
|
|
)
|
|
except httpx.RequestError as e:
|
|
logger.error("[pricebot] request failed: %s", e)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=f"pricebot upstream unreachable: {e}",
|
|
) from e
|
|
|
|
if resp.status_code >= 500:
|
|
logger.error(
|
|
"[pricebot] 5xx status=%d body=%s",
|
|
resp.status_code,
|
|
resp.text[:500],
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=f"pricebot upstream returned {resp.status_code}",
|
|
)
|
|
|
|
resp_json = resp.json()
|
|
|
|
# 领券结果沉淀(资产):中间帧 last_coupon_result + done 帧 coupon_results 幂等写库。
|
|
# 当前只记录、不参与"要不要领"判断(MVP 先不去重)。写库失败不影响返回。
|
|
if device_id:
|
|
results = _extract_coupon_results(resp_json)
|
|
if results:
|
|
try:
|
|
await run_in_threadpool(
|
|
_record_claims_blocking, device_id, user_id, trace_id, results
|
|
)
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning("coupon claim write failed: %s", e)
|
|
|
|
# 整轮跑完(done 帧)→ 记一条"今日已完成",首页「去领取」卡据此置灰。
|
|
# pricebot 把中途单券 done 改写成 wait+continue=true,只有整套全跑完那帧才保留
|
|
# command=="done"(见 pricebot main.py),故 done 已等价"整轮完成"(用户决策 A 方案:
|
|
# 到 done 即算,不管单券成败),无需再判 continue。写库失败绝不连累领券返回。
|
|
action = resp_json.get("action") or {}
|
|
if device_id and action.get("command") == "done":
|
|
try:
|
|
await run_in_threadpool(
|
|
_mark_completed_blocking, device_id, user_id, trace_id
|
|
)
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning("coupon completion write failed: %s", e)
|
|
|
|
return resp_json
|
|
|
|
|
|
@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** 不再自动弹。
|
|
|
|
频控主判据(管跨重装):弹出即占用今天这个 App 的"一次"。用户领/拒/无视都算用掉。
|
|
后续点领取/拒绝再由 step/dismiss 把 type 升级。按 (device, package, 日) 记。
|
|
"""
|
|
coupon_repo.mark_engagement(
|
|
db, payload.device_id, payload.package, payload.user_id, "shown"
|
|
)
|
|
return {"ok": True}
|
|
|
|
|
|
@router.post("/prompt/dismiss", summary="用户拒绝/关闭领券引导窗(记今日已 engage)")
|
|
def coupon_prompt_dismiss(payload: CouponPromptDismissIn, db: DbSession) -> dict[str, bool]:
|
|
"""客户端点关闭引导窗时调用 → 记一条今日 engagement(dismissed),今天**这个 App** 不再弹。
|
|
|
|
server 在透传链路里看不到"用户拒绝"(拒绝不发起领券),故必须客户端通知。
|
|
频控按 (device, package, 日),各 App 独立。MVP 不鉴权,按 device_id 记。
|
|
"""
|
|
coupon_repo.mark_engagement(
|
|
db, payload.device_id, payload.package, payload.user_id, "dismissed"
|
|
)
|
|
return {"ok": True}
|
|
|
|
|
|
@router.get(
|
|
"/prompt/should-show",
|
|
response_model=CouponPromptShouldShowOut,
|
|
summary="切到外卖 App 时是否还应弹领券引导窗",
|
|
)
|
|
def coupon_prompt_should_show(
|
|
device_id: str, db: DbSession, package: str = ""
|
|
) -> CouponPromptShouldShowOut:
|
|
"""今天这台设备**这个 App** 已 engage(弹/领/拒)过 → should_show=false。各 App 独立:
|
|
美团弹过不压淘宝/京东。客户端切到目标 App 时带 package 查(老客户端不带 → "" 全局态)。"""
|
|
return CouponPromptShouldShowOut(
|
|
should_show=not coupon_repo.has_engaged_today(db, device_id, package)
|
|
)
|
|
|
|
|
|
@router.post("/prompt/reset", summary="重置今日领券引导窗 engagement(开发测频控用)")
|
|
def coupon_prompt_reset(payload: CouponPromptDismissIn, db: DbSession) -> dict[str, bool]:
|
|
"""删这台设备今天的 engagement → has_engaged_today 变 false,今天又能弹。
|
|
开发设置「重置今日领券弹窗状态」按钮调。MVP 不鉴权,按 device_id。"""
|
|
coupon_repo.reset_today_engagement(db, payload.device_id)
|
|
return {"ok": True}
|
|
|
|
|
|
@router.get(
|
|
"/completed-today",
|
|
response_model=CouponCompletedTodayOut,
|
|
summary="这台设备今天是否已跑完整轮领券(到 done 帧)",
|
|
)
|
|
def coupon_completed_today(
|
|
device_id: str, db: DbSession
|
|
) -> CouponCompletedTodayOut:
|
|
"""今天这台设备已跑完整轮(到 done)→ completed=true。客户端据此把首页「去领取」
|
|
卡置灰、不可点(用户决策 A 方案:到 done 即算,不管单券成败)。判断维度 device_id
|
|
必须与领券循环上报的一致(客户端两端都用 ANDROID_ID)。"""
|
|
return CouponCompletedTodayOut(
|
|
completed=coupon_repo.has_completed_today(db, device_id)
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/completed-today/reset",
|
|
summary="重置今日已完成(开发设置全重置用,恢复首页「去领取」卡可点)",
|
|
)
|
|
def coupon_completed_today_reset(
|
|
payload: CouponPromptDismissIn, db: DbSession
|
|
) -> dict[str, bool]:
|
|
"""删这台设备今天的 completion → has_completed_today 变 false,首页「去领取」卡恢复可点。
|
|
与 /prompt/reset 配套:开发设置「重置今日领券弹窗状态」一键把今日状态全清。MVP 不鉴权。"""
|
|
coupon_repo.reset_today_completion(db, payload.device_id)
|
|
return {"ok": True}
|
|
|
|
|
|
@router.get(
|
|
"/stats",
|
|
response_model=CouponStatsOut,
|
|
summary="累计领券数(「我的」页战绩卡「领取优惠券 X 张」)",
|
|
)
|
|
def coupon_stats(user: CurrentUser, db: DbSession) -> CouponStatsOut:
|
|
"""该登录用户累计领到的券数(SUM(claimed_count),口径见 coupon_repo.sum_claimed_count)。
|
|
**鉴权(CurrentUser)**——区别于同文件不鉴权的 /step 透传与 /prompt 频控(那些按 device_id):
|
|
个人战绩按 user_id 聚合,必须有登录态。"""
|
|
return CouponStatsOut(coupon_count=coupon_repo.sum_claimed_count(db, user.id))
|