Compare commits

...

2 Commits

Author SHA1 Message Date
zhuzihao 357b2312af chore(deploy): 穿山甲收益拉取 systemd 定时器(每天10:30)+ 运维手册 (#100)
#92 的 scripts/sync_pangle_revenue.py 补 systemd 部署单元,让穿山甲 GroMore T+1 后台收益每天自动拉取入库,免人工敲命令。纯部署/文档文件,不改运行时代码。

- service:oneshot,跑 `--days 3` 回补近 3 天(幂等 upsert,扛偶发漏跑与穿山甲历史订正);10min 硬超时;加固块对齐主服务。
- timer:每天 10:30 触发(穿山甲约 10:00 出数留余量),Persistent 宕机后补跑。
- md:运维手册——上线前置(.env 填 PANGLE_REPORT_* 凭证、子账号需授权否则接口 118)、部署/健康检查/参数/注意事项(join key 用 ad_unit_id 非 code_id 等)。
- 风格对齐现有 deploy/daily-exchange.* 与 deploy/meituan-etl.*。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #100
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-30 22:59:41 +08:00
zhuzihao 78d970f420 feat: 领券数据看板 — coupon_session 流水表 + admin 聚合接口 (#99)
- 新表 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>
2026-06-30 22:59:36 +08:00
15 changed files with 901 additions and 1 deletions
@@ -0,0 +1,32 @@
"""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")
+81
View File
@@ -0,0 +1,81 @@
"""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")
@@ -0,0 +1,32 @@
"""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")
+2
View File
@@ -21,6 +21,7 @@ from app.admin.routers.audit import router as audit_router
from app.admin.routers.auth import router as auth_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.comparison import router as comparison_router
from app.admin.routers.config import router as config_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.cps import router as cps_router
from app.admin.routers.dashboard import router as dashboard_router from app.admin.routers.dashboard import router as dashboard_router
from app.admin.routers.device_liveness import router as device_liveness_router from app.admin.routers.device_liveness import router as device_liveness_router
@@ -101,6 +102,7 @@ admin_app.include_router(audit_router)
admin_app.include_router(config_router) admin_app.include_router(config_router)
admin_app.include_router(comparison_router) admin_app.include_router(comparison_router)
admin_app.include_router(cps_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_audit_router)
admin_app.include_router(ad_config_router) admin_app.include_router(ad_config_router)
admin_app.include_router(ad_revenue_router) admin_app.include_router(ad_revenue_router)
+225
View File
@@ -0,0 +1,225 @@
"""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 口径)→ 北京时间小时(023)。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)}
+106
View File
@@ -0,0 +1,106 @@
"""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"],
)
+83
View File
@@ -0,0 +1,83 @@
"""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):
"""按北京小时(023)趋势(单日 granularity=hour 时非空)。"""
hour: int = Field(..., description="北京时间小时 023")
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
+29
View File
@@ -29,6 +29,7 @@ from app.schemas.coupon_state import (
CouponPromptDismissIn, CouponPromptDismissIn,
CouponPromptShouldShowOut, CouponPromptShouldShowOut,
CouponPromptShownIn, CouponPromptShownIn,
CouponSessionIn,
CouponStatsOut, CouponStatsOut,
) )
@@ -194,6 +195,34 @@ async def coupon_step(
return resp_json 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)") @router.post("/prompt/shown", summary="领券引导窗弹出即上报(按 App 记 shown)")
def coupon_prompt_shown(payload: CouponPromptShownIn, db: DbSession) -> dict[str, bool]: def coupon_prompt_shown(payload: CouponPromptShownIn, db: DbSession) -> dict[str, bool]:
"""客户端弹出引导窗那刻调 → 记一条今日 engagement(shown),今天**这个 App** 不再自动弹。 """客户端弹出引导窗那刻调 → 记一条今日 engagement(shown),今天**这个 App** 不再自动弹。
+1
View File
@@ -19,6 +19,7 @@ from app.models.coupon_state import ( # noqa: F401
CouponClaimRecord, CouponClaimRecord,
CouponDailyCompletion, CouponDailyCompletion,
CouponPromptEngagement, CouponPromptEngagement,
CouponSession,
) )
from app.models.feedback import Feedback # noqa: F401 from app.models.feedback import Feedback # noqa: F401
from app.models.invite import InviteRelation # noqa: F401 from app.models.invite import InviteRelation # noqa: F401
+76
View File
@@ -181,3 +181,79 @@ class CouponPromptEngagement(Base):
f"<CouponPromptEngagement device={self.device_id} " f"<CouponPromptEngagement device={self.device_id} "
f"date={self.engage_date} type={self.engage_type}>" 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}>"
)
+90 -1
View File
@@ -6,7 +6,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from datetime import date, datetime from datetime import date, datetime, timezone
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from sqlalchemy import delete, func, select from sqlalchemy import delete, func, select
@@ -17,6 +17,7 @@ from app.models.coupon_state import (
CouponClaimRecord, CouponClaimRecord,
CouponDailyCompletion, CouponDailyCompletion,
CouponPromptEngagement, CouponPromptEngagement,
CouponSession,
) )
logger = logging.getLogger("shagua.coupon_state") logger = logging.getLogger("shagua.coupon_state")
@@ -232,3 +233,91 @@ def sum_claimed_count(db: Session, user_id: int) -> int:
) )
).scalar_one() ).scalar_one()
return int(total or 0) 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()
+25
View File
@@ -49,3 +49,28 @@ class CouponStatsOut(BaseModel):
""" """
coupon_count: int 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
+67
View File
@@ -0,0 +1,67 @@
# 穿山甲 GroMore 收益拉取 定时任务 — 运维手册
> 对象:维护「每天拉穿山甲后台收益入库」这套定时任务的同事。
> 🔒 服务器登录信息见**私密交接清单**,不入库。
## 它是什么
admin「广告收益报表」里的「穿山甲后台收益(T+1)」读的是**本地表 `ad_pangle_daily_revenue` 的快照,不是实时查穿山甲**。穿山甲只通过 GroMore 数据 API 给数、且 **T+1**(次日约 10:00 出昨天的数),所以每天得拉一次入库,报表才会往前走。
- 每天 10:30 跑一轮 `scripts/sync_pangle_revenue.py`,默认 `--days 3` 回补近 3 天。
- 维度 = 日期 × 应用(site_id)× 广告位(ad_unit_id);指标 = `revenue`(预估)+ `api_revenue`(结算口径)。
- **幂等 upsert**:同一(日期×应用×代码位)重跑只覆盖、不重复,故回补 / 重跑 / catch-up 都安全。
- 穿山甲无用户/设备维度 → 只能落「汇总/趋势级」,报表带 user_id 过滤时这块收益置空(显示「-」)。
## 文件
| 项 | 路径 |
|---|---|
| 脚本入口 | `scripts/sync_pangle_revenue.py` |
| 拉取 / 签名 | `app/integrations/pangle_report.py`(签名=参数字典序拼接+secure_key 后 MD5) |
| 入库表 | `ad_pangle_daily_revenue`(读写在 `app/repositories/ad_pangle_revenue.py`) |
| 凭证 | `.env``PANGLE_REPORT_USER_ID` / `PANGLE_REPORT_ROLE_ID` / `PANGLE_REPORT_SECURITY_KEY` |
| 应用映射 | `.env``PANGLE_REPORT_SITE_ID_PROD`(5830519)/ `PANGLE_REPORT_SITE_ID_TEST`(5832303) |
| systemd 单元 | `deploy/pangle-revenue.{service,timer}` |
## 上线前置(只做一次)
1. **填凭证**:后台「接入中心 → GroMore-API」领 user_id / role_id / Security Key(`secure_key`,**≠ 发奖 m-key**),填进线上 `.env``PANGLE_REPORT_*`。三项填齐脚本才工作,缺任一 → no-op 退出。
2. **子账号要授权**:线上若用子账号(role_id ≠ user_id),需主账号给它授「查看全部数据」,否则接口报 **118**(无权限)。本人自查自己(user_id=role_id)无此问题。
3. 凭证泄露:后台「重置 key 值」即可,改完同步线上 `.env` 重跑。
## 部署(Linux 服务器,需 root)
```bash
sudo cp deploy/pangle-revenue.{service,timer} /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now pangle-revenue.timer
systemctl list-timers pangle-revenue.timer # 确认下次触发时间(应是次日 10:30)
```
## 怎么看健康 / 手动跑一次
```bash
sudo systemctl start pangle-revenue.service # 立即手动跑一轮(不等 10:30)
journalctl -u pangle-revenue -n 30 --no-pager # 看日志:拉取区间 / 入库行数 / 新增更新 / 预估收益合计
```
成功日志形如:`✅ 完成:接口 N 行 → 入库 M 行(跳过 x),新增 a / 更新 b;预估收益合计 ¥19.42`
> 看不到收益、提示 `PANGLE_REPORT_* 未配置`→ 回「上线前置」补 `.env`;报 118 → 子账号没授「查看全部数据」。
## 本机 Windows 开发(无 systemd)
直接手动跑:
```
.venv\Scripts\python -m scripts.sync_pangle_revenue # 拉昨天
.venv\Scripts\python -m scripts.sync_pangle_revenue --days 3 # 回补近 3 天
.venv\Scripts\python -m scripts.sync_pangle_revenue --date 2026-06-27 # 指定单天
.venv\Scripts\python -m scripts.sync_pangle_revenue --start 2026-06-01 --end 2026-06-27 # 区间回补
```
## 脚本参数
- 无参:拉**昨天**(北京时间)。timer 用的是 `--days 3`
- `--days N`:从昨天起往前回补 N 天(含昨天)。
- `--date YYYY-MM-DD`:指定单天。
- `--start / --end`:指定闭区间(跨度 ≤ 31 天,接口上限 1 个月,超了报 114)。
## 注意事项
- **触发时间**:`OnCalendar=*-*-* 10:30:00`。穿山甲 ~10:00 出数,故别早于 10:00 跑(会拉到空/不全)。
- **catch-up**:`Persistent=true` 补跑错过的那一轮;叠加 `--days 3`,漏一两天重新触发即自愈。
- **今天 / 今天以前要分开查**:脚本默认只拉昨天及更早,不混查今天(接口约束),无需关心。
- **join key 是 `ad_unit_id`(我们配的 104xxx)不是 `code_id`**:`code_id` 是底层各 ADN 代码位,对不上口径;`ad_unit_id='-1'` 是未归因桶。改维度时务必注意(详见脚本头注释)。
- **`api_revenue` 很稀疏**:测试应用 ADN 没配 Reporting → 全 0,仅 prod 个别位有;`revenue`(预估)才是稳的主力。
- **DB 无关**:sqlite / postgres 均可(upsert 逐行 select-then-write,不像美团 ETL 需要 PG)。
- **别和别的触发方式双跑**:本 systemd timer 与「手动 cron / 进程内任务」二选一,虽幂等不会重复入库,纯属多余。
- **改脚本 / 改部署**:走 git + PR,由有 root 的人部署。
+38
View File
@@ -0,0 +1,38 @@
# 每天拉穿山甲 GroMore T+1 天级收益入库 —— 单轮跑,由 pangle-revenue.timer 每天 10:30 触发。
# 落 ad_pangle_daily_revenue 表,供 admin 广告收益报表的「穿山甲后台收益(T+1)」区块。
#
# 仅用于 Linux 服务器;本机 Windows 开发无 systemd,直接手动跑脚本即可:
# .venv\Scripts\python -m scripts.sync_pangle_revenue # 拉昨天(北京时间)
# .venv\Scripts\python -m scripts.sync_pangle_revenue --days 3 # 回补近 3 天
#
# 部署(服务器):
# sudo cp deploy/pangle-revenue.{service,timer} /etc/systemd/system/
# sudo systemctl daemon-reload && sudo systemctl enable --now pangle-revenue.timer
# # 手动跑一次验证: sudo systemctl start pangle-revenue.service && journalctl -u pangle-revenue -n 30
#
# 前置:.env 配好 PANGLE_REPORT_USER_ID / PANGLE_REPORT_ROLE_ID / PANGLE_REPORT_SECURITY_KEY
# (后台「接入中心 → GroMore-API」领;子账号需主账号授「查看全部数据」否则接口 118)。
# 未配齐这三项 → 脚本自动 no-op 退出,免动 timer。
[Unit]
Description=Sync Pangle GroMore daily revenue (T+1, one-shot, driven by timer)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=root
WorkingDirectory=/opt/shaguabijia-app-server
Environment="PATH=/opt/shaguabijia-app-server/.venv/bin:/usr/bin:/bin"
EnvironmentFile=/opt/shaguabijia-app-server/.env
# 默认拉昨天;--days 3 回补近 3 天(幂等 upsert,应对偶发漏跑 + 穿山甲对历史数据订正,重跑无害)。
ExecStart=/opt/shaguabijia-app-server/.venv/bin/python -m scripts.sync_pangle_revenue --days 3
SyslogIdentifier=pangle-revenue
# 仅几个 HTTP 请求 + 小批量入库,通常数秒;给 10min 硬超时防穿山甲接口卡死。
TimeoutStartSec=600
# 与主服务 shaguabijia-app-server.service 同款加固。
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/opt/shaguabijia-app-server
ProtectHome=true
+14
View File
@@ -0,0 +1,14 @@
# 每天 10:30 触发一次穿山甲 GroMore T+1 收益拉取入库(Linux 服务器用)。
# 见 pangle-revenue.service 顶部注释的部署步骤。
[Unit]
Description=Run Pangle GroMore daily revenue sync at 10:30
[Timer]
# 穿山甲 T+1、次日约 10:00 出数;10:30 触发留 30min 余量。要错开整点扎堆可微调到 10:35。
OnCalendar=*-*-* 10:30:00
# 服务器宕机/重启后,补跑错过的那一轮(而不是干等次日);叠加 --days 3 回补,漏一两天能自愈。
Persistent=true
AccuracySec=1min
[Install]
WantedBy=timers.target