Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 824045dd19 | |||
| 930eff822c | |||
| 285e46ebaf | |||
| 3630fb7b3a | |||
| 2ddea4159d | |||
| 37fd51a498 | |||
| fa4127b9e5 | |||
| 0cf5b3816f | |||
| 9e082e6376 | |||
| 93cb283c1b | |||
| a43e082391 | |||
| e11ad9449c | |||
| 6ec4cfb4be |
@@ -0,0 +1,72 @@
|
||||
"""analytics_selfstat tables
|
||||
|
||||
Revision ID: 11c44afbea58
|
||||
Revises: admin_user_plain_password
|
||||
Create Date: 2026-07-08 16:32:49.351817
|
||||
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '11c44afbea58'
|
||||
down_revision: str | Sequence[str] | None = 'admin_user_plain_password'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'analytics_selfstat',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('device_id', sa.String(length=64), nullable=False),
|
||||
sa.Column('epoch_id', sa.String(length=64), nullable=False),
|
||||
sa.Column('app_ver', sa.String(length=32), nullable=True),
|
||||
sa.Column('oem', sa.String(length=32), nullable=True),
|
||||
sa.Column('os', sa.String(length=32), nullable=True),
|
||||
sa.Column('batches_attempted', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.Column('batches_ok', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.Column('batches_fail', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.Column('retries', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.Column('queue_depth', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('sent_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True),
|
||||
server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
with op.batch_alter_table('analytics_selfstat', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_analytics_selfstat_created_at'), ['created_at'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_analytics_selfstat_device_id'), ['device_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_analytics_selfstat_epoch_id'), ['epoch_id'], unique=False)
|
||||
|
||||
op.create_table(
|
||||
'analytics_selfstat_event',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('snapshot_id', sa.Integer(), nullable=False),
|
||||
sa.Column('event', sa.String(length=64), nullable=False),
|
||||
sa.Column('attempted', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.Column('drop_capture', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.Column('delivered', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.Column('drop_undelivered', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.ForeignKeyConstraint(['snapshot_id'], ['analytics_selfstat.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
with op.batch_alter_table('analytics_selfstat_event', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_analytics_selfstat_event_event'), ['event'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_analytics_selfstat_event_snapshot_id'), ['snapshot_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('analytics_selfstat_event', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_analytics_selfstat_event_snapshot_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_analytics_selfstat_event_event'))
|
||||
op.drop_table('analytics_selfstat_event')
|
||||
with op.batch_alter_table('analytics_selfstat', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_analytics_selfstat_epoch_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_analytics_selfstat_device_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_analytics_selfstat_created_at'))
|
||||
op.drop_table('analytics_selfstat')
|
||||
@@ -0,0 +1,44 @@
|
||||
"""ad_ecpm_record.trace_id(展示收益归属到比价/领券 trace)
|
||||
|
||||
信息流(Draw)展示 eCPM 上报时带上本场比价/领券 trace_id,落此列;领券数据 / 比价记录看板
|
||||
按 trace_id 聚合"本次广告收益"。激励视频/福利/旧客户端为 NULL。
|
||||
|
||||
本迁移原以 (11c44afbea58, merge_pages_override_coupon_slot) 为双亲、顺带收敛双 head,
|
||||
但与它并行落 main 的 merge_selfstat_coupon_slot 已用同一对双亲做了纯收敛 → 同一对
|
||||
父节点出现两个收敛点、main 上又成双 head。故重挂到该 merge 之后成单链(仅改链接、
|
||||
schema 改动不变;两文件都保留,已 stamp 在 merge 上的库可直接线性升级)。
|
||||
|
||||
Revision ID: ad_ecpm_trace_id
|
||||
Revises: merge_selfstat_coupon_slot
|
||||
Create Date: 2026-07-10
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "ad_ecpm_trace_id"
|
||||
down_revision: Union[str, Sequence[str], None] = "merge_selfstat_coupon_slot"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# SQLite 下 ADD COLUMN(可空)与 CREATE INDEX 均原生支持,无需 batch_alter_table
|
||||
# (同 ad_feed_reward_trace_id 迁移)。
|
||||
op.add_column(
|
||||
"ad_ecpm_record",
|
||||
sa.Column("trace_id", sa.String(length=64), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_ad_ecpm_record_trace_id"),
|
||||
"ad_ecpm_record",
|
||||
["trace_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_ad_ecpm_record_trace_id"), table_name="ad_ecpm_record")
|
||||
op.drop_column("ad_ecpm_record", "trace_id")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""coupon_claim_record 加 app_env 列(领券所属 session 环境;每券成功率表按它过滤 prod/dev)
|
||||
|
||||
Revision ID: coupon_claim_app_env
|
||||
Revises: coupon_session_platform_success
|
||||
Create Date: 2026-07-08 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "coupon_claim_app_env"
|
||||
down_revision: str | Sequence[str] | None = "coupon_session_platform_success"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("coupon_claim_record", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("app_env", sa.String(length=16), nullable=True))
|
||||
batch_op.create_index(
|
||||
"ix_coupon_claim_record_app_env", ["app_env"], unique=False
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("coupon_claim_record", schema=None) as batch_op:
|
||||
batch_op.drop_index("ix_coupon_claim_record_app_env")
|
||||
batch_op.drop_column("app_env")
|
||||
@@ -0,0 +1,37 @@
|
||||
"""coupon_session 加 platform_success 列(本次至少领到一张的平台 id 列表)
|
||||
|
||||
供 admin「领券数据」算 ②整单成功率 / ③点位成功率(平台粒度)。数据落点:服务端 /step 逐帧
|
||||
按 trace_id 并集写入(见 app/repositories/coupon_state.merge_session_platform_success)。旧行 NULL
|
||||
视作空集,已建表环境靠它补列、全新环境顺序应用不重复加列。设计:docs/guides/领券成功率指标-设计与埋点.md。
|
||||
|
||||
Revision ID: coupon_session_platform_success
|
||||
Revises: admin_user_plain_password
|
||||
Create Date: 2026-07-07 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_platform_success"
|
||||
down_revision: str | Sequence[str] | None = "admin_user_plain_password"
|
||||
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:
|
||||
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("platform_success", _JSON, nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
|
||||
batch_op.drop_column("platform_success")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""合并两个 alembic head:admin_user_pages_override(#126 权限)+ coupon_claim_app_env(领券成功率)。
|
||||
|
||||
两条迁移都从 admin_user_plain_password 分叉——#126 经 pull main 进入本分支,领券成功率为本分支新增——
|
||||
于是出现两个 head。本迁移仅把二者收敛成单 head,让 `alembic upgrade head`(单数,部署/run.sh 用)
|
||||
恢复正常;**不含任何表结构 / 数据改动**(纯 merge)。
|
||||
|
||||
Revision ID: merge_pages_override_coupon_slot
|
||||
Revises: admin_user_pages_override, coupon_claim_app_env
|
||||
Create Date: 2026-07-09 00:00:00.000000
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
revision: str = "merge_pages_override_coupon_slot"
|
||||
down_revision: str | Sequence[str] | None = (
|
||||
"admin_user_pages_override",
|
||||
"coupon_claim_app_env",
|
||||
)
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""纯合并 head,无 schema 改动。"""
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""拆回两个 head,无 schema 改动。"""
|
||||
@@ -0,0 +1,29 @@
|
||||
"""合并两个 alembic head:11c44afbea58(#127 埋点健康度 selfstat)+ merge_pages_override_coupon_slot(#130 自带的合并迁移)。
|
||||
|
||||
三条分支都从 admin_user_plain_password 分叉(#126 权限 / #127 selfstat / #130 领券成功率)。
|
||||
#130 自带的 merge 创建时本地 main 尚无 #127 的 11c44afbea58,只收敛了 #126 + 自身两条,
|
||||
#130 合入后 main 上仍留两个 head → `alembic upgrade head`(单数,部署/run.sh 用)直接报错、服务起不来。
|
||||
本迁移仅把二者收敛成单 head;**不含任何表结构 / 数据改动**(纯 merge)。
|
||||
|
||||
Revision ID: merge_selfstat_coupon_slot
|
||||
Revises: 11c44afbea58, merge_pages_override_coupon_slot
|
||||
Create Date: 2026-07-10 00:00:00.000000
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
revision: str = "merge_selfstat_coupon_slot"
|
||||
down_revision: str | Sequence[str] | None = (
|
||||
"11c44afbea58",
|
||||
"merge_pages_override_coupon_slot",
|
||||
)
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""纯合并 head,无 schema 改动。"""
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""拆回两个 head,无 schema 改动。"""
|
||||
@@ -26,6 +26,7 @@ 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
|
||||
from app.admin.routers.ops_stat_config import router as ops_stat_config_router
|
||||
from app.admin.routers.analytics_health import router as analytics_health_router
|
||||
from app.admin.routers.event_logs import router as event_logs_router
|
||||
from app.admin.routers.feedback import router as feedback_router
|
||||
from app.admin.routers.feedback_qr import router as feedback_qr_router
|
||||
@@ -97,6 +98,7 @@ admin_app.include_router(withdraw_router)
|
||||
admin_app.include_router(price_report_router)
|
||||
admin_app.include_router(feedback_router)
|
||||
admin_app.include_router(event_logs_router)
|
||||
admin_app.include_router(analytics_health_router)
|
||||
admin_app.include_router(feedback_qr_router)
|
||||
admin_app.include_router(admins_router)
|
||||
admin_app.include_router(roles_router)
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""埋点健康度聚合(埋点成功率 / 上报成功率)。
|
||||
|
||||
只存原始累计快照,查询时在 Python 侧差分聚合(admin 低频、量级小,跨 PG/SQLite 无方言坑;
|
||||
与 cps.py / coupon_data.py 同款约定)。差分按 (device_id, epoch_id, event) 分区、created_at
|
||||
升序,相邻做差、负值夹 0;每增量按其快照 created_at 归入北京天桶。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
from app.models.analytics_selfstat import AnalyticsSelfStat as H
|
||||
from app.models.analytics_selfstat import AnalyticsSelfStatEvent as E
|
||||
|
||||
_COUNTS = ("attempted", "drop_capture", "delivered", "drop_undelivered")
|
||||
|
||||
|
||||
def diff_snapshots(rows: list[dict]) -> list[dict]:
|
||||
"""累计快照行 → 每快照增量行(纯逻辑)。
|
||||
|
||||
rows 每行含 device_id/epoch_id/event/created_at/app_ver/oem/os + 四个累计计数。
|
||||
返回每行含 dims + created_at + 四个增量 d_*(分区首行增量=累计值;负值夹 0)。
|
||||
"""
|
||||
parts: dict[tuple, list[dict]] = defaultdict(list)
|
||||
for r in rows:
|
||||
parts[(r["device_id"], r["epoch_id"], r["event"])].append(r)
|
||||
|
||||
out: list[dict] = []
|
||||
for group in parts.values():
|
||||
group.sort(key=lambda r: (r["created_at"], r.get("id", 0)))
|
||||
prev = {k: 0 for k in _COUNTS}
|
||||
for r in group:
|
||||
deltas = {f"d_{k}": max(0, int(r[k]) - prev[k]) for k in _COUNTS}
|
||||
out.append({
|
||||
"device_id": r["device_id"], "epoch_id": r["epoch_id"], "event": r["event"],
|
||||
"created_at": r["created_at"], "app_ver": r["app_ver"],
|
||||
"oem": r["oem"], "os": r["os"], **deltas,
|
||||
})
|
||||
prev = {k: int(r[k]) for k in _COUNTS}
|
||||
return out
|
||||
|
||||
|
||||
def _cn_day(dt: datetime) -> str:
|
||||
"""created_at(UTC 口径)→ 北京日期字符串 YYYY-MM-DD。naive 当 UTC,tz-aware 直接换算。"""
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
return dt.astimezone(rewards.CN_TZ).date().isoformat()
|
||||
|
||||
|
||||
def _rates(sums: dict) -> dict:
|
||||
"""由四个增量和派生两段率(分母 0 → None)。"""
|
||||
persisted_denom = sums["attempted"]
|
||||
report_denom = sums["delivered"] + sums["drop_undelivered"]
|
||||
return {
|
||||
**sums,
|
||||
"track_success_rate": (
|
||||
(sums["attempted"] - sums["drop_capture"]) / persisted_denom
|
||||
if persisted_denom else None
|
||||
),
|
||||
"report_success_rate": (
|
||||
sums["delivered"] / report_denom if report_denom else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _sum_deltas(deltas: list[dict]) -> dict:
|
||||
return {k: sum(d[f"d_{k}"] for d in deltas) for k in _COUNTS}
|
||||
|
||||
|
||||
def _fetch_rows(db: Session, date_from: datetime, date_to: datetime) -> list[dict]:
|
||||
"""取 [from, to) 区间行 + 每分区在 from 左侧的最后一条基线行(供第一条区间增量做差)。"""
|
||||
cols = (
|
||||
H.id, H.device_id, H.epoch_id, E.event, H.created_at,
|
||||
H.app_ver, H.oem, H.os,
|
||||
E.attempted, E.drop_capture, E.delivered, E.drop_undelivered,
|
||||
)
|
||||
in_range = db.execute(
|
||||
select(*cols).join(E, E.snapshot_id == H.id)
|
||||
.where(H.created_at >= date_from, H.created_at < date_to)
|
||||
).mappings().all()
|
||||
|
||||
# 注:基线子查询无下界扫 from 左侧全量(spec §7 已接受的取舍;量级变大再上物化 rollup)。
|
||||
# 用 max(id) 而非 max(created_at) 选"最新一条":id 严格单调,避免 SQLite 秒级时间戳撞车时选歧义。
|
||||
sub = (
|
||||
select(H.device_id, H.epoch_id, E.event, func.max(H.id).label("max_id"))
|
||||
.join(E, E.snapshot_id == H.id)
|
||||
.where(H.created_at < date_from)
|
||||
.group_by(H.device_id, H.epoch_id, E.event)
|
||||
.subquery()
|
||||
)
|
||||
baseline = db.execute(
|
||||
select(*cols).join(E, E.snapshot_id == H.id).join(
|
||||
sub, sub.c.max_id == H.id
|
||||
)
|
||||
).mappings().all()
|
||||
|
||||
return [dict(r) for r in list(baseline) + list(in_range)]
|
||||
|
||||
|
||||
def _in_range_deltas(db: Session, date_from: datetime, date_to: datetime) -> list[dict]:
|
||||
"""差分后只保留 created_at ∈ [from, to) 的增量(基线行被差分用后丢弃)。
|
||||
|
||||
Python 侧过滤需对齐 tz 口径:SQLite 返回 naive UTC,PG 返回 aware UTC。
|
||||
统一转成 naive UTC 再比较,兼容两种后端。
|
||||
"""
|
||||
def _to_naive_utc(dt: datetime) -> datetime:
|
||||
if dt.tzinfo is not None:
|
||||
return dt.astimezone(UTC).replace(tzinfo=None)
|
||||
return dt
|
||||
|
||||
from_naive = _to_naive_utc(date_from)
|
||||
to_naive = _to_naive_utc(date_to)
|
||||
deltas = diff_snapshots(_fetch_rows(db, date_from, date_to))
|
||||
return [d for d in deltas if from_naive <= _to_naive_utc(d["created_at"]) < to_naive]
|
||||
|
||||
|
||||
def overview(db: Session, date_from: datetime, date_to: datetime) -> dict:
|
||||
deltas = _in_range_deltas(db, date_from, date_to)
|
||||
return _rates(_sum_deltas(deltas))
|
||||
|
||||
|
||||
def trend(db: Session, date_from: datetime, date_to: datetime) -> list[dict]:
|
||||
deltas = _in_range_deltas(db, date_from, date_to)
|
||||
by_day: dict[str, list[dict]] = defaultdict(list)
|
||||
for d in deltas:
|
||||
by_day[_cn_day(d["created_at"])].append(d)
|
||||
return [
|
||||
{"day": day, **_rates(_sum_deltas(items))}
|
||||
for day, items in sorted(by_day.items())
|
||||
]
|
||||
|
||||
|
||||
def breakdown(db: Session, date_from: datetime, date_to: datetime, dim: str) -> list[dict]:
|
||||
if dim not in ("event", "app_ver", "oem"):
|
||||
raise ValueError(f"invalid dim: {dim!r}")
|
||||
deltas = _in_range_deltas(db, date_from, date_to)
|
||||
by_key: dict[str, list[dict]] = defaultdict(list)
|
||||
for d in deltas:
|
||||
by_key[d.get(dim) or "(unknown)"].append(d)
|
||||
rows = [{"key": key, **_rates(_sum_deltas(items))} for key, items in by_key.items()]
|
||||
rows.sort(key=lambda r: (r["report_success_rate"] is None, r["report_success_rate"] or 0.0))
|
||||
return rows
|
||||
@@ -5,17 +5,21 @@
|
||||
- 发起数 = 区间内全部 session(含 started/completed/failed/abandoned),= 流失统计的基数。
|
||||
- 完成数 / 耗时均值 / 分位 = 仅 status==completed 子集(成功跑完才有可比的"领券耗时")。
|
||||
- summary/daily/hourly/total 在全量上算,不受分页;items 为排序后当前页。
|
||||
- 另含 coupon_slot_report(数据源 coupon_claim_record):按 coupon_id「按券成功率」表,见设计 §13。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date as _date, datetime
|
||||
from datetime import UTC, datetime
|
||||
from datetime import date as _date
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy import case, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
from app.models.coupon_state import CouponSession
|
||||
from app.models.coupon_state import CouponClaimRecord, CouponSession
|
||||
from app.models.user import User
|
||||
from app.repositories import ad_ecpm as crud_ecpm
|
||||
from app.repositories.coupon_state import DEFAULT_PLATFORMS, coupon_id_to_platform
|
||||
|
||||
|
||||
def _cn_hour(dt: datetime) -> int:
|
||||
@@ -42,7 +46,47 @@ 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:
|
||||
def _success_rates(rows: list) -> dict:
|
||||
"""平台粒度成功率(见 docs/guides/领券成功率指标-设计与埋点.md §3/§12):
|
||||
|
||||
- sel(s) = 勾选平台(`platforms` 空 → 全领三档 DEFAULT_PLATFORMS);
|
||||
- succ(s) = `platform_success` ∩ sel(至少领到一张的平台);
|
||||
- ② 整单成功率 = #{sel⊆succ 且 sel≠∅} / 发起数;
|
||||
- ③ 点位成功率 = Σ|succ| / Σ|sel|;per_platform[p] = 勾了 p 且成功 / 勾了 p。
|
||||
基数含全部 session(started/completed/failed/abandoned),与「发起数」同基数。
|
||||
"""
|
||||
started = len(rows)
|
||||
full_success = 0
|
||||
point_success = 0
|
||||
point_total = 0
|
||||
per_succ = {p: 0 for p in DEFAULT_PLATFORMS}
|
||||
per_total = {p: 0 for p in DEFAULT_PLATFORMS}
|
||||
for r in rows:
|
||||
sel = set(r.platforms) if r.platforms else set(DEFAULT_PLATFORMS)
|
||||
succ = set(r.platform_success or []) & sel
|
||||
point_success += len(succ)
|
||||
point_total += len(sel)
|
||||
if sel and succ == sel:
|
||||
full_success += 1
|
||||
for p in sel:
|
||||
if p in per_total: # 只统计三档已知平台;未知/非法平台 id 不进 per_platform
|
||||
per_total[p] += 1
|
||||
if p in succ:
|
||||
per_succ[p] += 1
|
||||
return {
|
||||
"full_success_count": full_success,
|
||||
"full_success_rate": round(full_success / started, 4) if started else None,
|
||||
"point_success_count": point_success,
|
||||
"point_total_count": point_total,
|
||||
"point_success_rate": round(point_success / point_total, 4) if point_total else None,
|
||||
"per_platform": {
|
||||
p: (round(per_succ[p] / per_total[p], 4) if per_total[p] else None)
|
||||
for p in DEFAULT_PLATFORMS
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _session_to_row(r, phone: str | None = None, nickname: str | None = None, ad_revenue_yuan: float = 0.0) -> dict:
|
||||
"""CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。"""
|
||||
return {
|
||||
"id": r.id,
|
||||
@@ -61,6 +105,7 @@ def _session_to_row(r, phone: str | None = None, nickname: str | None = None) ->
|
||||
"started_at": r.started_at,
|
||||
"claimed_count": r.claimed_count,
|
||||
"trace_url": r.trace_url,
|
||||
"ad_revenue_yuan": ad_revenue_yuan,
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +129,7 @@ def coupon_data_report(
|
||||
date_to: str,
|
||||
user: str | None = None,
|
||||
app_env: str | None = None,
|
||||
statuses: list[str] | None = None,
|
||||
granularity: str = "day",
|
||||
limit: int = 500,
|
||||
offset: int = 0,
|
||||
@@ -93,6 +139,8 @@ def coupon_data_report(
|
||||
|
||||
- user:手机号/昵称模糊搜(匹配不到任何用户 → 空结果)。
|
||||
- app_env:prod/dev 精确;None=全部。
|
||||
- statuses:领券状态多选(started/completed/failed/abandoned);None/空=全部。整个视图
|
||||
(汇总/成功率/趋势/明细)按选中状态算,与 app_env 同级过滤(方案 A)。
|
||||
- sort:time=发起时刻倒序(默认) / elapsed=全程耗时倒序(None 末尾)。
|
||||
"""
|
||||
by_hour = granularity == "hour"
|
||||
@@ -115,6 +163,8 @@ def coupon_data_report(
|
||||
)
|
||||
if app_env is not None:
|
||||
stmt = stmt.where(CouponSession.app_env == app_env)
|
||||
if statuses:
|
||||
stmt = stmt.where(CouponSession.status.in_(statuses))
|
||||
if user_ids is not None:
|
||||
stmt = stmt.where(CouponSession.user_id.in_(user_ids))
|
||||
rows = list(db.execute(stmt).scalars())
|
||||
@@ -131,6 +181,7 @@ def coupon_data_report(
|
||||
"p50_ms": _percentile(completed_elapsed, 50),
|
||||
"p95_ms": _percentile(completed_elapsed, 95),
|
||||
"p99_ms": _percentile(completed_elapsed, 99),
|
||||
**_success_rates(rows),
|
||||
}
|
||||
|
||||
# ── 按天趋势(柱=发起/完成数,线=平均耗时)──
|
||||
@@ -197,10 +248,11 @@ def coupon_data_report(
|
||||
select(User.id, User.phone, User.nickname).where(User.id.in_(uids))
|
||||
).all()
|
||||
}
|
||||
rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in page])
|
||||
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))
|
||||
items.append(_session_to_row(r, phone, nickname, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0)))
|
||||
|
||||
return {
|
||||
"summary": summary,
|
||||
@@ -222,4 +274,57 @@ def coupon_user_records(db: Session, *, user_id: int, limit: int = 100) -> dict:
|
||||
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)}
|
||||
rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in rows])
|
||||
return {
|
||||
"items": [_session_to_row(r, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0)) for r in rows],
|
||||
"total": int(total),
|
||||
}
|
||||
|
||||
|
||||
_SLOT_OK = ("success", "already_claimed")
|
||||
_SLOT_TRIED = ("success", "already_claimed", "failed")
|
||||
|
||||
|
||||
def coupon_slot_report(
|
||||
db: Session, *, date_from: str, date_to: str, app_env: str | None = None
|
||||
) -> dict:
|
||||
"""按 coupon_id(具体券)聚合成功率(见 docs/guides/领券成功率指标-设计与埋点.md §13)。
|
||||
|
||||
数据源 coupon_claim_record(粒度=设备-天,唯一键 device+coupon+day)。
|
||||
- 尝试 = status ∈ {success, already_claimed, failed}(skipped 排除);
|
||||
- 成功 = status ∈ {success, already_claimed};成功率 = 成功/尝试;
|
||||
- claim_date 区间 + app_env(None=全部)过滤;按 tried 倒序返回。
|
||||
"""
|
||||
d_from = _date.fromisoformat(date_from)
|
||||
d_to = _date.fromisoformat(date_to)
|
||||
ok = case((CouponClaimRecord.status.in_(_SLOT_OK), 1), else_=0)
|
||||
stmt = (
|
||||
select(
|
||||
CouponClaimRecord.coupon_id,
|
||||
func.max(CouponClaimRecord.coupon_name).label("coupon_name"),
|
||||
func.count().label("tried"),
|
||||
func.sum(ok).label("succeeded"),
|
||||
)
|
||||
.where(
|
||||
CouponClaimRecord.claim_date >= d_from,
|
||||
CouponClaimRecord.claim_date <= d_to,
|
||||
CouponClaimRecord.status.in_(_SLOT_TRIED),
|
||||
)
|
||||
.group_by(CouponClaimRecord.coupon_id)
|
||||
)
|
||||
if app_env is not None:
|
||||
stmt = stmt.where(CouponClaimRecord.app_env == app_env)
|
||||
items = []
|
||||
for coupon_id, coupon_name, tried, succeeded in db.execute(stmt).all():
|
||||
tried = int(tried or 0)
|
||||
succeeded = int(succeeded or 0)
|
||||
items.append({
|
||||
"coupon_id": coupon_id,
|
||||
"coupon_name": coupon_name,
|
||||
"platform": coupon_id_to_platform(coupon_id),
|
||||
"tried": tried,
|
||||
"succeeded": succeeded,
|
||||
"success_rate": round(succeeded / tried, 4) if tried else None,
|
||||
})
|
||||
items.sort(key=lambda x: (-x["tried"], x["coupon_id"]))
|
||||
return {"items": items}
|
||||
|
||||
@@ -32,6 +32,7 @@ from app.models.wallet import (
|
||||
InviteCashTransaction,
|
||||
WithdrawOrder,
|
||||
)
|
||||
from app.repositories import ad_ecpm
|
||||
|
||||
# 「最近活跃」计入的行为事件(与大盘 DAU/留存活跃口径一致:开始比价 + 开始领券)
|
||||
_ACTIVE_EVENTS = (COMPARE_START_EVENT, COUPON_START_EVENT)
|
||||
@@ -298,6 +299,11 @@ def list_comparison_records(
|
||||
limit=limit, cursor=cursor,
|
||||
)
|
||||
_attach_user_info(db, items)
|
||||
# 「本次比价看广告的预估收益」:按本页 trace_id 一次性聚合(同 _attach_user_info 逐页范式)。
|
||||
# ad_revenue_yuan 非 ORM 列,仅瞬态挂实例上供 AdminComparisonListItem(from_attributes)读出。
|
||||
rev = ad_ecpm.revenue_yuan_by_trace(db, [it.trace_id for it in items])
|
||||
for it in items:
|
||||
it.ad_revenue_yuan = rev.get(it.trace_id, 0.0)
|
||||
return items, next_cursor, total
|
||||
|
||||
|
||||
|
||||
@@ -30,11 +30,17 @@ from app.models.user import User
|
||||
from app.models.wallet import CoinTransaction, WithdrawOrder
|
||||
|
||||
_BEIJING = timezone(timedelta(hours=8))
|
||||
COUPON_REWARD_BIZ_TYPES = ("reward_video", "ad_reward", "coupon", "coupon_reward")
|
||||
REWARD_VIDEO_BIZ_TYPES = ("reward_video", "ad_reward")
|
||||
# 领券/比价奖励金币的真实来源是信息流广告发奖(ad_feed_reward_record,按 feed_scene 分场景);
|
||||
# coin_transaction 里只有扁平的 feed_ad_reward、biz_type 不分 coupon/comparison,故这俩桶历史从未
|
||||
# 被写入,仅留作未来兜底,实际金额在下方按 feed_scene 汇总 ad_feed_reward_record 得出。reward_video/
|
||||
# ad_reward 是激励视频,单独成桶、不再混进领券奖励(历史误并会把激励视频金币双计进领券)。
|
||||
COUPON_REWARD_BIZ_TYPES = ("coupon", "coupon_reward")
|
||||
COMPARISON_REWARD_BIZ_TYPES = ("comparison", "compare_reward", "comparison_reward")
|
||||
EXCLUDED_REWARD_BIZ_TYPES = ("invite_inviter", "invite_invitee", "admin_grant")
|
||||
UNCLASSIFIED_FEED_BIZ_TYPES = ("feed_ad_reward",)
|
||||
REGULAR_TASK_EXCLUDED_BIZ_TYPES = (
|
||||
*REWARD_VIDEO_BIZ_TYPES,
|
||||
*COUPON_REWARD_BIZ_TYPES,
|
||||
*COMPARISON_REWARD_BIZ_TYPES,
|
||||
*EXCLUDED_REWARD_BIZ_TYPES,
|
||||
@@ -362,7 +368,7 @@ def dashboard_overview(
|
||||
period_reward_video_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.in_(("reward_video", "ad_reward")),
|
||||
CoinTransaction.biz_type.in_(REWARD_VIDEO_BIZ_TYPES),
|
||||
)
|
||||
period_feed_ad_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
@@ -384,15 +390,29 @@ def dashboard_overview(
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.like("task_%"),
|
||||
)
|
||||
# 领券/比价奖励金币 = biz_type 桶(历史空,兜底)+ 该场景信息流广告实发金币
|
||||
# (ad_feed_reward_record.feed_scene,granted;reward_date 是北京日期串,与 period 同自然日窗口)。
|
||||
period_coupon_reward_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.in_(COUPON_REWARD_BIZ_TYPES),
|
||||
) + _sum(
|
||||
AdFeedRewardRecord.coin,
|
||||
AdFeedRewardRecord.status == "granted",
|
||||
AdFeedRewardRecord.feed_scene == "coupon",
|
||||
AdFeedRewardRecord.reward_date >= period_from.isoformat(),
|
||||
AdFeedRewardRecord.reward_date <= period_to.isoformat(),
|
||||
)
|
||||
period_comparison_reward_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.in_(COMPARISON_REWARD_BIZ_TYPES),
|
||||
) + _sum(
|
||||
AdFeedRewardRecord.coin,
|
||||
AdFeedRewardRecord.status == "granted",
|
||||
AdFeedRewardRecord.feed_scene == "comparison",
|
||||
AdFeedRewardRecord.reward_date >= period_from.isoformat(),
|
||||
AdFeedRewardRecord.reward_date <= period_to.isoformat(),
|
||||
)
|
||||
period_regular_task_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
@@ -543,7 +563,7 @@ def dashboard_overview(
|
||||
"reward_video_coin_total": _sum(
|
||||
CoinTransaction.amount,
|
||||
CoinTransaction.amount > 0,
|
||||
CoinTransaction.biz_type.in_(("reward_video", "ad_reward")),
|
||||
CoinTransaction.biz_type.in_(REWARD_VIDEO_BIZ_TYPES),
|
||||
),
|
||||
"reward_video_watch_count": _count(
|
||||
AdRewardRecord,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""admin 埋点健康度:埋点成功率 / 上报成功率 总览 + 趋势 + 下钻(只读)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.admin.deps import AdminDb, get_current_admin
|
||||
from app.admin.repositories import analytics_health as repo
|
||||
from app.admin.schemas.analytics_health import (
|
||||
HealthBreakdownRow,
|
||||
HealthMetrics,
|
||||
HealthTrendPoint,
|
||||
)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/analytics-health",
|
||||
tags=["admin-analytics-health"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/overview", response_model=HealthMetrics, summary="两段成功率总览")
|
||||
def overview(
|
||||
db: AdminDb,
|
||||
date_from: Annotated[datetime, Query()],
|
||||
date_to: Annotated[datetime, Query()],
|
||||
) -> HealthMetrics:
|
||||
return HealthMetrics(**repo.overview(db, date_from, date_to))
|
||||
|
||||
|
||||
@router.get("/trend", response_model=list[HealthTrendPoint], summary="按北京天趋势")
|
||||
def trend(
|
||||
db: AdminDb,
|
||||
date_from: Annotated[datetime, Query()],
|
||||
date_to: Annotated[datetime, Query()],
|
||||
) -> list[HealthTrendPoint]:
|
||||
return [HealthTrendPoint(**p) for p in repo.trend(db, date_from, date_to)]
|
||||
|
||||
|
||||
@router.get("/breakdown", response_model=list[HealthBreakdownRow], summary="按维度下钻")
|
||||
def breakdown(
|
||||
db: AdminDb,
|
||||
date_from: Annotated[datetime, Query()],
|
||||
date_to: Annotated[datetime, Query()],
|
||||
dim: Annotated[str, Query(pattern="^(event|app_ver|oem)$")] = "event",
|
||||
) -> list[HealthBreakdownRow]:
|
||||
return [HealthBreakdownRow(**r) for r in repo.breakdown(db, date_from, date_to, dim)]
|
||||
@@ -18,6 +18,8 @@ from app.admin.schemas.coupon_data import (
|
||||
CouponDataOut,
|
||||
CouponDataRow,
|
||||
CouponDataSummary,
|
||||
CouponSlotRow,
|
||||
CouponSlotsOut,
|
||||
CouponUserRecordsOut,
|
||||
)
|
||||
from app.core.rewards import cn_today
|
||||
@@ -52,6 +54,10 @@ def get_coupon_data(
|
||||
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",
|
||||
status: Annotated[
|
||||
list[str] | None,
|
||||
Query(description="领券状态多选 started/completed/failed/abandoned;不传=全部"),
|
||||
] = None,
|
||||
granularity: Annotated[
|
||||
str, Query(description="day=按天 / hour=按小时(北京);区间>1 天建议 day")
|
||||
] = "day",
|
||||
@@ -73,7 +79,7 @@ def get_coupon_data(
|
||||
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,
|
||||
user=user, app_env=env, statuses=status, granularity=granularity,
|
||||
limit=limit, offset=offset, sort=sort,
|
||||
)
|
||||
return CouponDataOut(
|
||||
@@ -87,6 +93,35 @@ def get_coupon_data(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/coupons",
|
||||
response_model=CouponSlotsOut,
|
||||
summary="按券成功率(coupon_id 粒度;成功/(成功+失败),skipped 排除,设备-天口径)",
|
||||
)
|
||||
def get_coupon_slots(
|
||||
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,
|
||||
app_env: Annotated[str, Query(description="prod(默认) / dev / all(全部环境)")] = "prod",
|
||||
) -> CouponSlotsOut:
|
||||
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} 天")
|
||||
env = None if app_env == "all" else app_env
|
||||
result = coupon_data.coupon_slot_report(
|
||||
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(), app_env=env
|
||||
)
|
||||
return CouponSlotsOut(
|
||||
date_from=d_from.isoformat(),
|
||||
date_to=d_to.isoformat(),
|
||||
items=[CouponSlotRow(**r) for r in result["items"]],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/user-records",
|
||||
response_model=CouponUserRecordsOut,
|
||||
|
||||
@@ -19,6 +19,8 @@ from app.admin.schemas.ops_marquee_seed import (
|
||||
OpsMarqueeSeedCreate,
|
||||
OpsMarqueeSeedOut,
|
||||
OpsMarqueeSeedUpdate,
|
||||
OpsRealRecordItem,
|
||||
OpsRealRecordsOut,
|
||||
OpsSavingsFeedPreviewOut,
|
||||
)
|
||||
from app.models.admin import AdminUser
|
||||
@@ -68,6 +70,22 @@ def preview_feed(
|
||||
return OpsSavingsFeedPreviewOut(items=ops_marquee.get_feed(db, limit=limit, mode=mode))
|
||||
|
||||
|
||||
@router.get("/real-records", response_model=OpsRealRecordsOut, summary="分页浏览当前模式下可展示的记录(审核用)")
|
||||
def list_real_records(
|
||||
db: AdminDb,
|
||||
mode: Annotated[str | None, Query(description="mixed/real/seed;不传=当前持久化模式")] = None,
|
||||
offset: Annotated[int, Query(ge=0)] = 0,
|
||||
limit: Annotated[int, Query(ge=1, le=50)] = 8,
|
||||
) -> OpsRealRecordsOut:
|
||||
"""分页列出**当前模式**下可在 app 轮播展示的全部记录(**不去重**):只真实=真实记录;只种子=各启用
|
||||
种子按生成逻辑各出一行;混播=真实+种子。与 app 同口径洗牌+去连簇,固定种子→翻页稳定、能翻遍全部。
|
||||
item.user_id=0 表示种子行。"""
|
||||
if mode is not None and mode not in ops_marquee.FEED_MODES:
|
||||
raise HTTPException(status_code=400, detail="mode 需为 mixed / real / seed")
|
||||
items, total = ops_marquee.list_real_records(db, mode=mode, offset=offset, limit=limit)
|
||||
return OpsRealRecordsOut(items=[OpsRealRecordItem(**it) for it in items], total=total)
|
||||
|
||||
|
||||
# 注:/mode 两个端点须在 /{seed_id} 之前注册,否则 PATCH /mode 会被 /{seed_id} 抢先按 id 解析。
|
||||
@router.get("/mode", summary="首页轮播数据源模式(mixed/real/seed)")
|
||||
def get_feed_mode(db: AdminDb) -> dict:
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""埋点健康度 admin 响应 schema。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class HealthMetrics(BaseModel):
|
||||
attempted: int
|
||||
drop_capture: int
|
||||
delivered: int
|
||||
drop_undelivered: int
|
||||
track_success_rate: float | None
|
||||
report_success_rate: float | None
|
||||
|
||||
|
||||
class HealthTrendPoint(HealthMetrics):
|
||||
day: str
|
||||
|
||||
|
||||
class HealthBreakdownRow(HealthMetrics):
|
||||
key: str
|
||||
@@ -41,6 +41,7 @@ class AdminComparisonListItem(BaseModel):
|
||||
rom_name: str | None = None
|
||||
android_version: str | None = None
|
||||
app_version: str | None = None
|
||||
ad_revenue_yuan: float = 0.0 # 本次比价看的信息流广告预估收益(元),queries 瞬态挂 ORM 实例上
|
||||
created_at: datetime
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,16 @@ class CouponDataSummary(BaseModel):
|
||||
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)")
|
||||
# 平台粒度成功率(见 docs/guides/领券成功率指标-设计与埋点.md):基数含全部 session。
|
||||
full_success_count: int = Field(0, description="整单成功数(勾选平台全部领到的 session 数)")
|
||||
full_success_rate: float | None = Field(None, description="整单成功率②=整单成功数/发起数;无数据为空")
|
||||
point_success_count: int = Field(0, description="成功平台点位数(Σ 每次成功的平台数)")
|
||||
point_total_count: int = Field(0, description="总平台点位数(Σ 每次勾选平台数;空勾选=全领三档)")
|
||||
point_success_rate: float | None = Field(None, description="点位成功率③=成功点位/总点位;无数据为空")
|
||||
per_platform: dict[str, float | None] = Field(
|
||||
default_factory=dict,
|
||||
description="分平台点位成功率 {平台id: rate|None};恒含美团/淘宝/京东三档,区间内无人勾选的平台为 None",
|
||||
)
|
||||
|
||||
|
||||
class CouponDataDaily(BaseModel):
|
||||
@@ -60,6 +70,9 @@ class CouponDataRow(BaseModel):
|
||||
started_at: datetime = Field(..., description="发起时刻(明细「时间」列)")
|
||||
claimed_count: int | None = None
|
||||
trace_url: str | None = Field(None, description="pricebot 公网 trace 链接(仅 completed 有);admin 渲染可点链接,无则显示可复制 trace_id")
|
||||
ad_revenue_yuan: float = Field(
|
||||
0.0, description="本次领券看的信息流广告预估收益(元);按 trace_id 聚合 ad_ecpm_record"
|
||||
)
|
||||
|
||||
|
||||
class CouponDataOut(BaseModel):
|
||||
@@ -81,3 +94,22 @@ class CouponUserRecordsOut(BaseModel):
|
||||
|
||||
items: list[CouponDataRow]
|
||||
total: int
|
||||
|
||||
|
||||
class CouponSlotRow(BaseModel):
|
||||
"""按券成功率一行(§13):粒度=设备-天;成功率=成功/(成功+失败),skipped 排除。"""
|
||||
|
||||
coupon_id: str
|
||||
coupon_name: str | None = None
|
||||
platform: str | None = Field(None, description="美团/淘宝/京东 平台 id;无法识别为空")
|
||||
tried: int = Field(..., description="尝试数(success+already_claimed+failed 的设备-天数)")
|
||||
succeeded: int = Field(..., description="成功数(success+already_claimed)")
|
||||
success_rate: float | None = Field(None, description="成功率=成功/尝试")
|
||||
|
||||
|
||||
class CouponSlotsOut(BaseModel):
|
||||
"""按券成功率表响应(§13)。"""
|
||||
|
||||
date_from: str
|
||||
date_to: str
|
||||
items: list[CouponSlotRow]
|
||||
|
||||
@@ -62,3 +62,16 @@ class OpsSavingsFeedPreviewItem(BaseModel):
|
||||
class OpsSavingsFeedPreviewOut(BaseModel):
|
||||
"""运营预览:实际混播出来的 feed(真实记录会插队,与客户端一致)。"""
|
||||
items: list[OpsSavingsFeedPreviewItem]
|
||||
|
||||
|
||||
class OpsRealRecordItem(BaseModel):
|
||||
masked_user: str # 脱敏后展示名(与 app 一致)
|
||||
saved_amount_cents: int # 节省金额(分)
|
||||
created_at: str # 比价记录时间(YYYY-MM-DD HH:MM)
|
||||
user_id: int # 真实 user_id(供运营核对,不下发客户端)
|
||||
|
||||
|
||||
class OpsRealRecordsOut(BaseModel):
|
||||
"""分页浏览「全部可展示的真实记录」(success+省>0,不去重、稳定顺序)。"""
|
||||
items: list[OpsRealRecordItem]
|
||||
total: int # 满足条件的真实记录总数(算页数用)
|
||||
|
||||
@@ -286,6 +286,7 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm
|
||||
ad_session_id=payload.ad_session_id,
|
||||
adn=payload.adn, slot_id=payload.slot_id,
|
||||
feed_scene=payload.feed_scene,
|
||||
trace_id=payload.trace_id,
|
||||
app_env=payload.app_env, our_code_id=payload.our_code_id,
|
||||
)
|
||||
logger.info(
|
||||
|
||||
+16
-1
@@ -6,13 +6,18 @@ POST /api/v1/analytics/events — 批量接收新手引导(及后续)埋点,appe
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.api.deps import DbSession
|
||||
from app.repositories import analytics as analytics_repo
|
||||
from app.repositories import analytics_selfstat as selfstat_repo
|
||||
from app.schemas.analytics import AnalyticsBatchIn, AnalyticsIngestOut
|
||||
from app.schemas.analytics_selfstat import SelfStatBatchIn, SelfStatIngestOut
|
||||
|
||||
router = APIRouter(prefix="/api/v1/analytics", tags=["analytics"])
|
||||
logger = logging.getLogger("shagua.analytics")
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
@@ -29,3 +34,13 @@ def ingest_events(
|
||||
) -> AnalyticsIngestOut:
|
||||
n = analytics_repo.record_batch(db, batch, client_ip=_client_ip(request))
|
||||
return AnalyticsIngestOut(received=n)
|
||||
|
||||
|
||||
@router.post("/selfstat", response_model=SelfStatIngestOut, summary="上报自报计数快照")
|
||||
def ingest_selfstat(batch: SelfStatBatchIn, db: DbSession) -> SelfStatIngestOut:
|
||||
try:
|
||||
snap_id = selfstat_repo.record_selfstat(db, batch)
|
||||
except Exception: # noqa: BLE001 — 计数链路要稳,落库失败不裸奔 500,记日志回明确错误
|
||||
logger.exception("selfstat ingest failed device=%s epoch=%s", batch.device_id, batch.epoch_id)
|
||||
raise HTTPException(status_code=503, detail="selfstat ingest failed") from None
|
||||
return SelfStatIngestOut(snapshot_id=snap_id)
|
||||
|
||||
+10
-1
@@ -81,7 +81,16 @@ 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)
|
||||
# 取本次 session 环境,给 coupon_claim_record 打 app_env 标(每券成功率表按它过滤;设计 §13)。
|
||||
app_env = coupon_repo.session_app_env(db, trace_id)
|
||||
coupon_repo.record_claims(db, device_id, user_id, trace_id, results, app_env=app_env)
|
||||
# 顺带把本帧「成功平台」并入 coupon_session.platform_success(admin 领券数据 ②整单/③点位成功率;
|
||||
# 设计 route B,见 docs/guides/领券成功率指标-设计与埋点.md)。复用同一 SessionLocal、紧接 record_claims,
|
||||
# 不新增连接;并集幂等(无新平台不写),trace_id 缺失或 session 行未落库则跳过。
|
||||
if trace_id:
|
||||
coupon_repo.merge_session_platform_success(
|
||||
db, trace_id, coupon_repo.succeeded_platforms(results)
|
||||
)
|
||||
|
||||
|
||||
def _mark_completed_blocking(
|
||||
|
||||
+14
-2
@@ -43,6 +43,7 @@ from app.schemas.welfare import (
|
||||
WithdrawRequest,
|
||||
WithdrawResultOut,
|
||||
WithdrawStatusOut,
|
||||
WithdrawTierOut,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.wallet")
|
||||
@@ -173,8 +174,15 @@ def unbind_wechat(
|
||||
return UnbindWechatResultOut(bound=False)
|
||||
|
||||
|
||||
@router.get("/withdraw-info", response_model=WithdrawInfoOut, summary="提现额度/绑定状态/免确认开关")
|
||||
def withdraw_info(user: CurrentUser, db: DbSession) -> WithdrawInfoOut:
|
||||
@router.get("/withdraw-info", response_model=WithdrawInfoOut, summary="提现额度/绑定状态/免确认开关/档位")
|
||||
def withdraw_info(
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
source: str = Query(
|
||||
"coin_cash",
|
||||
description="提现账户:coin_cash(福利页,下发 tiers 档位) / invite_cash(邀请页,tiers 为空走旧逻辑)",
|
||||
),
|
||||
) -> WithdrawInfoOut:
|
||||
u = db.get(User, user.id)
|
||||
# 顺带同步免确认授权状态(捕获首单确认后已生效的授权 pending→active),让开关展示实时
|
||||
auth = crud_wallet.sync_transfer_auth(db, user.id)
|
||||
@@ -185,6 +193,7 @@ def withdraw_info(user: CurrentUser, db: DbSession) -> WithdrawInfoOut:
|
||||
wechat_nickname=u.wechat_nickname if u else None,
|
||||
wechat_avatar_url=u.wechat_avatar_url if u else None,
|
||||
transfer_auth_enabled=bool(auth and auth.state == "active"),
|
||||
tiers=[WithdrawTierOut(**t) for t in crud_wallet.withdraw_tier_states(db, user.id, source)],
|
||||
)
|
||||
|
||||
|
||||
@@ -218,6 +227,9 @@ def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> Withdraw
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="已有提现申请正在审核或打款中,请处理完成后再申请",
|
||||
) from e
|
||||
except crud_wallet.WithdrawTierUnavailableError as e:
|
||||
# 福利页档位闸(7-9):次数满/已选其他额度。正常客户端已按 tiers 预拦,此处兜底防绕过。
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="今日额度已达上限") from e
|
||||
except crud_wallet.InsufficientCashError as e:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="现金余额不足") from e
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import NamedTuple
|
||||
|
||||
# 业务时区:签到的"今天"按北京时间算,不能用 UTC。
|
||||
# 否则 UTC+8 的凌晨 0~8 点会被算成 UTC 的前一天,导致签到日期错位。
|
||||
@@ -52,6 +53,30 @@ WITHDRAW_MIN_CENTS: int = 10
|
||||
WITHDRAW_MAX_CENTS: int = 5_000_000 # 5 万元
|
||||
|
||||
|
||||
# ===== 提现档位(福利页 coin_cash;7-9 对齐原型 withdrawal.html)=====
|
||||
# 后端是档位唯一真相源:withdraw-info 按此下发,create_withdraw 按此校验(防绕过客户端刷)。
|
||||
# 规则(2026-07-09 拍板):
|
||||
# - 新人档(is_newbie):账号历史一次性,"发起就算用过"(任意状态含被拒),用过即不再下发;
|
||||
# 0.1 与 0.3 各自独立同天可各提一次,且不参与常规档"每日选一个额度"互斥。
|
||||
# - 常规档:按北京日计次(0.5×3 / 10×1 / 20×1),三档每天只能选一个。
|
||||
# invite_cash(邀请页)本轮无档位概念,不在此表。改档位=改这里发版。
|
||||
class WithdrawTier(NamedTuple):
|
||||
amount_cents: int
|
||||
label: str # 客户端档位方块展示文案
|
||||
badge: str | None # 角标文案;None=无角标
|
||||
daily_limit: int # 每日次数上限(新人档的"历史一次性"另由 is_newbie 判定)
|
||||
is_newbie: bool
|
||||
|
||||
|
||||
WITHDRAW_TIERS_COIN_CASH: tuple[WithdrawTier, ...] = (
|
||||
WithdrawTier(10, "0.1", "新人福利", 1, True),
|
||||
WithdrawTier(30, "0.3", "新人福利", 1, True),
|
||||
WithdrawTier(50, "0.5", None, 3, False),
|
||||
WithdrawTier(1000, "10", None, 1, False),
|
||||
WithdrawTier(2000, "20", None, 1, False),
|
||||
)
|
||||
|
||||
|
||||
# ===== 一次性任务(领一次,user_task 去重)=====
|
||||
TASK_ENABLE_NOTIFICATION = "enable_notification"
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@ from app.models.ad_watch_log import AdWatchLog # noqa: F401
|
||||
from app.models.admin import AdminAuditLog, AdminUser # noqa: F401
|
||||
from app.models.admin_role import AdminRole # noqa: F401
|
||||
from app.models.analytics_event import AnalyticsEvent # noqa: F401
|
||||
from app.models.analytics_selfstat import ( # noqa: F401
|
||||
AnalyticsSelfStat,
|
||||
AnalyticsSelfStatEvent,
|
||||
)
|
||||
from app.models.app_config import AppConfig # noqa: F401
|
||||
from app.models.comparison import ComparisonRecord # noqa: F401
|
||||
from app.models.cps_activity import CpsActivity # noqa: F401
|
||||
|
||||
@@ -32,6 +32,9 @@ class AdEcpmRecord(Base):
|
||||
# 点位场景:comparison(比价) / coupon(领券) / welfare(福利),供收益报表区分比价/领券 Draw 收益;
|
||||
# 仅信息流/Draw 上报(比价与领券共用同一代码位,只能客户端各调用点显式打标),激励视频为 NULL。
|
||||
feed_scene: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
# 本次比价/领券 trace_id(信息流场景客户端带上):把这条展示收益归属到对应比价/领券记录。
|
||||
# 领券数据 / 比价记录看板按 trace_id 聚合"本次广告收益"。激励视频/福利/旧客户端 = NULL。
|
||||
trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
# 客户端生成的一次广告会话 id;激励视频 S2S 回调 extra 会透传同值
|
||||
ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
# 实际投放的 ADN(穿山甲 getShowEcpm().getSdkName(),如 pangle / gdt)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""埋点/上报成功率自报计数快照表(append-only)。
|
||||
|
||||
客户端周期上报「自 epoch 起算的累计计数」;服务端只存原始快照,查询时在 Python 侧差分聚合
|
||||
(见 app/admin/repositories/analytics_health.py)。与既有 analytics_event 表完全独立。
|
||||
|
||||
- analytics_selfstat :一快照一行(快照头 + 设备维度 + 设备级诊断量)
|
||||
- analytics_selfstat_event :一 event 一行(四类累计计数),外键指向快照头
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class AnalyticsSelfStat(Base):
|
||||
__tablename__ = "analytics_selfstat"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
device_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||
epoch_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||
# 设备维度(每设备固定,下钻用)
|
||||
app_ver: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
oem: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
os: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
# 设备级诊断量(累计;queue_depth 是瞬时 gauge)
|
||||
batches_attempted: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
batches_ok: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
batches_fail: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
retries: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
queue_depth: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
sent_at: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # 端上报时刻 epoch ms
|
||||
# 服务端接收时间(权威,用于时间分桶与分区排序)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<AnalyticsSelfStat id={self.id} device={self.device_id} epoch={self.epoch_id}>"
|
||||
|
||||
|
||||
class AnalyticsSelfStatEvent(Base):
|
||||
__tablename__ = "analytics_selfstat_event"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
snapshot_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("analytics_selfstat.id"), index=True, nullable=False
|
||||
)
|
||||
event: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||
attempted: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
drop_capture: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
delivered: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
drop_undelivered: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<AnalyticsSelfStatEvent snap={self.snapshot_id} event={self.event}>"
|
||||
@@ -66,6 +66,9 @@ class CouponClaimRecord(Base):
|
||||
|
||||
# success / already_claimed / failed / skipped(原样取 pricebot coupon 结果)
|
||||
status: Mapped[str] = mapped_column(String(24), nullable=False)
|
||||
# 领券所属 session 的环境 prod/dev(/step 按 trace_id 查 coupon_session.app_env 打标)。
|
||||
# 旧行 NULL(不回填)。admin「按券成功率」表据此过滤环境。见设计 §13。
|
||||
app_env: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True)
|
||||
vendor: Mapped[str | None] = mapped_column(String(48), nullable=True)
|
||||
coupon_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# 这张领到几张(pricebot display_count;给不出时为 None)
|
||||
@@ -240,6 +243,10 @@ class CouponSession(Base):
|
||||
platform_elapsed: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
|
||||
# 领到总张数(收尾帧带)。
|
||||
claimed_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# 本次 session 至少领到一张(status∈{success,already_claimed})的平台 id 列表,如 ["meituan-waimai","jd-waimai"]。
|
||||
# admin「领券数据」据此算整单成功率(②)/点位成功率(③);服务端 /step 逐帧按 trace_id 并集写入
|
||||
# (见 coupon_state.merge_session_platform_success)。旧行=NULL → 视作空集。
|
||||
platform_success: Mapped[list | None] = mapped_column(_JSON, 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)
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
from app.core.rewards import cn_today
|
||||
from app.models.ad_ecpm import AdEcpmRecord
|
||||
|
||||
@@ -24,6 +25,7 @@ def create_ecpm_record(
|
||||
adn: str | None = None,
|
||||
slot_id: str | None = None,
|
||||
feed_scene: str | None = None,
|
||||
trace_id: str | None = None,
|
||||
app_env: str | None = None,
|
||||
our_code_id: str | None = None,
|
||||
) -> AdEcpmRecord:
|
||||
@@ -43,6 +45,7 @@ def create_ecpm_record(
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
feed_scene=feed_scene,
|
||||
trace_id=trace_id,
|
||||
app_env=app_env,
|
||||
our_code_id=our_code_id,
|
||||
ecpm_raw=ecpm_raw,
|
||||
@@ -105,3 +108,27 @@ def count_today(db: Session, user_id: int) -> int:
|
||||
AdEcpmRecord.report_date == cn_today().isoformat(),
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
|
||||
def revenue_yuan_by_trace(db: Session, trace_ids: list[str]) -> dict[str, float]:
|
||||
"""各 trace_id 的广告预估收益(元):按 trace_id 聚合 ad_ecpm_record 的展示收益。
|
||||
|
||||
单条展示收益 = min(eCPM元, AD_ECPM_MAX_FEN/100) / 1000(与 admin 广告收益报表同口径)。
|
||||
ecpm_raw 是字符串且需逐条钳顶,故取回后 Python 求和(行数=本页各 trace 的展示条数,很小)。
|
||||
trace_id 仅信息流(比价/领券)场景客户端带,激励视频/旧数据为 NULL,按 trace_id 过滤天然只算对应场景。
|
||||
只喂**当前页**的 trace_id(≤ 一页条数);空集合直接返回(避免 IN () 非法)。
|
||||
"""
|
||||
if not trace_ids:
|
||||
return {}
|
||||
rows = db.execute(
|
||||
select(AdEcpmRecord.trace_id, AdEcpmRecord.ecpm_raw).where(
|
||||
AdEcpmRecord.trace_id.in_(trace_ids),
|
||||
)
|
||||
).all()
|
||||
cap_yuan = rewards.AD_ECPM_MAX_FEN / 100.0
|
||||
out: dict[str, float] = {}
|
||||
for tid, ecpm_raw in rows:
|
||||
if not tid:
|
||||
continue
|
||||
out[tid] = out.get(tid, 0.0) + min(rewards.parse_ecpm_yuan(ecpm_raw), cap_yuan) / 1000.0
|
||||
return {tid: round(v, 6) for tid, v in out.items()}
|
||||
|
||||
@@ -176,10 +176,19 @@ def grant_feed_reward(
|
||||
)
|
||||
return _commit_record(db, rec, client_event_id)
|
||||
|
||||
# 按点位场景拆流水文案(2026-07):比价等候期看的广告 vs 领券时看的广告,在收益明细里分开显示。
|
||||
# feed_scene=comparison→比价奖励 / coupon→领券奖励;其它(welfare/空/旧端不带)维持通用「信息流广告奖励」。
|
||||
# 客户端按此 biz_type 直显固定文案(见 CoinHistoryViewModel.coinTitle),故 remark 只作后台留痕/兜底。
|
||||
if feed_scene == "comparison":
|
||||
reward_biz, reward_remark = "feed_ad_reward_comparison", "比价奖励"
|
||||
elif feed_scene == "coupon":
|
||||
reward_biz, reward_remark = "feed_ad_reward_coupon", "领券奖励"
|
||||
else:
|
||||
reward_biz, reward_remark = "feed_ad_reward", "信息流广告奖励"
|
||||
crud_wallet.grant_coins(
|
||||
db, user_id, coin,
|
||||
biz_type="feed_ad_reward", ref_id=client_event_id,
|
||||
remark="信息流广告奖励",
|
||||
biz_type=reward_biz, ref_id=client_event_id,
|
||||
remark=reward_remark,
|
||||
)
|
||||
rec = AdFeedRewardRecord(
|
||||
client_event_id=client_event_id,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""自报计数快照落库。一次事务:插 1 条快照头 + N 条 event 行,返回快照 id。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.analytics_selfstat import AnalyticsSelfStat, AnalyticsSelfStatEvent
|
||||
from app.schemas.analytics_selfstat import SelfStatBatchIn
|
||||
|
||||
|
||||
def record_selfstat(db: Session, batch: SelfStatBatchIn) -> int:
|
||||
snap = AnalyticsSelfStat(
|
||||
device_id=batch.device_id,
|
||||
epoch_id=batch.epoch_id,
|
||||
app_ver=batch.app_ver,
|
||||
oem=batch.oem,
|
||||
os=batch.os,
|
||||
batches_attempted=batch.batches_attempted,
|
||||
batches_ok=batch.batches_ok,
|
||||
batches_fail=batch.batches_fail,
|
||||
retries=batch.retries,
|
||||
queue_depth=batch.queue_depth,
|
||||
sent_at=batch.sent_at,
|
||||
)
|
||||
db.add(snap)
|
||||
db.flush() # 拿到 snap.id
|
||||
db.add_all([
|
||||
AnalyticsSelfStatEvent(
|
||||
snapshot_id=snap.id,
|
||||
event=e.event,
|
||||
attempted=e.attempted,
|
||||
drop_capture=e.drop_capture,
|
||||
delivered=e.delivered,
|
||||
drop_undelivered=e.drop_undelivered,
|
||||
)
|
||||
for e in batch.events
|
||||
])
|
||||
db.commit()
|
||||
return snap.id
|
||||
@@ -147,12 +147,22 @@ def reset_today_completion(db: Session, device_id: str) -> int:
|
||||
|
||||
# ===== 领券记录(coupon_claim_record)=====
|
||||
|
||||
def session_app_env(db: Session, trace_id: str | None) -> str | None:
|
||||
"""按 trace_id 取 coupon_session.app_env(每券成功率表打环境标用);无 trace_id / 查不到 → None。"""
|
||||
if not trace_id:
|
||||
return None
|
||||
return db.execute(
|
||||
select(CouponSession.app_env).where(CouponSession.trace_id == trace_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def record_claims(
|
||||
db: Session,
|
||||
device_id: str,
|
||||
user_id: int | None,
|
||||
trace_id: str | None,
|
||||
results: list[dict],
|
||||
app_env: str | None = None,
|
||||
) -> int:
|
||||
"""一批券领取结果幂等写入,返回写入(新增 + 更新)条数。
|
||||
|
||||
@@ -186,12 +196,15 @@ def record_claims(
|
||||
row.user_id = user_id
|
||||
if count is not None:
|
||||
row.claimed_count = count
|
||||
if app_env is not None:
|
||||
row.app_env = app_env
|
||||
row.extra = r
|
||||
else:
|
||||
db.add(CouponClaimRecord(
|
||||
device_id=device_id, user_id=user_id,
|
||||
coupon_id=coupon_id, claim_date=today,
|
||||
status=status, vendor=r.get("vendor"), coupon_name=r.get("name"),
|
||||
status=status, app_env=app_env,
|
||||
vendor=r.get("vendor"), coupon_name=r.get("name"),
|
||||
claimed_count=count, trace_id=trace_id, reason=r.get("reason"),
|
||||
extra=r,
|
||||
))
|
||||
@@ -235,6 +248,47 @@ def sum_claimed_count(db: Session, user_id: int) -> int:
|
||||
return int(total or 0)
|
||||
|
||||
|
||||
# ===== 领券平台推导(coupon_id → 平台;成功平台集)=====
|
||||
|
||||
# 成功语义:success + already_claimed 算成功(pricebot 代码 emit already_claimed,协议 enum 漏了);
|
||||
# failed / skipped 不算。与 sum_claimed_count 同口径。
|
||||
_SUCCESS_STATUSES = frozenset({"success", "already_claimed"})
|
||||
|
||||
# 三档平台 id 及固定序(美团→淘宝→京东),与客户端 DEFAULT_PLATFORM_ORDER 对齐。
|
||||
DEFAULT_PLATFORMS: tuple[str, ...] = ("meituan-waimai", "taobao-shanguang", "jd-waimai")
|
||||
|
||||
|
||||
def coupon_id_to_platform(coupon_id: str | None) -> str | None:
|
||||
"""coupon_id 前缀 → 平台 id;无法识别 / 空 → None。
|
||||
|
||||
与客户端 `CouponForegroundService.couponIdToPlatform` 同词表:
|
||||
mt_→美团外卖 / tb_·ele_·elm_→淘宝闪购 / jd_→京东外卖。
|
||||
"""
|
||||
if not coupon_id:
|
||||
return None
|
||||
if coupon_id.startswith("mt_"):
|
||||
return "meituan-waimai"
|
||||
if coupon_id.startswith(("tb_", "ele_", "elm_")):
|
||||
return "taobao-shanguang"
|
||||
if coupon_id.startswith("jd_"):
|
||||
return "jd-waimai"
|
||||
return None
|
||||
|
||||
|
||||
def succeeded_platforms(results: list[dict]) -> list[str]:
|
||||
"""一批券结果 → 至少领到一张的平台集(按 DEFAULT_PLATFORMS 去重保序)。
|
||||
|
||||
只取 status∈{success, already_claimed} 的券;失败/跳过、无法识别平台的券跳过。
|
||||
"""
|
||||
ok: set[str] = set()
|
||||
for r in results:
|
||||
if r.get("status") in _SUCCESS_STATUSES:
|
||||
platform = coupon_id_to_platform(r.get("coupon_id"))
|
||||
if platform is not None:
|
||||
ok.add(platform)
|
||||
return [p for p in DEFAULT_PLATFORMS if p in ok]
|
||||
|
||||
|
||||
# ===== 领券任务流水(coupon_session,admin「领券数据」看板数据源)=====
|
||||
|
||||
def upsert_coupon_session(
|
||||
@@ -321,3 +375,31 @@ def upsert_coupon_session(
|
||||
except IntegrityError:
|
||||
# 并发下另一请求刚插了同 trace_id → 唯一约束撞,回滚忽略(本就幂等)。
|
||||
db.rollback()
|
||||
|
||||
|
||||
def merge_session_platform_success(
|
||||
db: Session, trace_id: str, platforms: list[str]
|
||||
) -> None:
|
||||
"""把本帧「成功平台」并入 coupon_session.platform_success(按 trace_id,并集幂等,按 DEFAULT_PLATFORMS 保序)。
|
||||
|
||||
- 领券 /step 每逢带券结果的帧调一次(平台成败布尔,跨帧取并集天然幂等,不重复计)。
|
||||
- 读不到该 trace_id 的行 → **静默跳过**(不建兜底行;设计 §5:started 帧几乎必先落库)。
|
||||
- 并集无变化(该平台已记过)→ 不写库,省一次 UPDATE。
|
||||
- fire-and-forget:调用方已吞异常;并发唯一冲突回滚忽略。
|
||||
"""
|
||||
if not platforms:
|
||||
return
|
||||
row = db.execute(
|
||||
select(CouponSession).where(CouponSession.trace_id == trace_id)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
return
|
||||
merged = set(row.platform_success or []) | set(platforms)
|
||||
new_list = [p for p in DEFAULT_PLATFORMS if p in merged]
|
||||
if new_list == (row.platform_success or []):
|
||||
return # 幂等:无新平台,不写
|
||||
row.platform_success = new_list
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
|
||||
@@ -217,12 +217,28 @@ def _recent_real_rows(db: Session) -> list[tuple[int, int, str | None]]:
|
||||
return out
|
||||
|
||||
|
||||
def _shuffle_declustered(rows: list, rng: random.Random | None = None) -> list:
|
||||
"""洗牌 + 「去连簇」:先洗牌,再贪心重排让相邻两条尽量不是同一 user_id(元素 [0] 即 user_id)。
|
||||
rng=None → 用全局 _rng(feed 每次新随机);传入 rng(如固定种子 Random)→ 排列确定(admin 稳定分页)。
|
||||
减少同一用户连续出现;只有少数几个用户时 best-effort。"""
|
||||
rng = rng or _rng
|
||||
pool = list(rows)
|
||||
rng.shuffle(pool)
|
||||
result: list = []
|
||||
while pool:
|
||||
prev_uid = result[-1][0] if result else None
|
||||
# 优先挑与上一条不同 user 的;挑不到(只剩同 user)才取第一个
|
||||
idx = next((i for i, r in enumerate(pool) if r[0] != prev_uid), 0)
|
||||
result.append(pool.pop(idx))
|
||||
return result
|
||||
|
||||
|
||||
def get_feed(db: Session, limit: int = 8, mode: str | None = None) -> list[dict]:
|
||||
"""返回最多 limit 条 {masked_user, saved_amount_cents, time(HH:MM:SS 北京)}。
|
||||
|
||||
mode:显式传入(admin 预览指定模式)则用它、**不改持久化配置**;不传(客户端 /savings-feed)读
|
||||
持久化的 marquee_feed_mode;非法值一律回退到持久化模式。
|
||||
真实条:success 且 0 < saved ≤ 上限,按 user 去重(同一用户只取最新一条,避免单人刷屏)。
|
||||
真实条:success 且 0 < saved ≤ 上限,**不按 user 去重**(打乱 + 去连簇:相邻尽量不同用户、减少单人连刷)后取前 limit。
|
||||
不足用启用的种子补齐——**公平随机抽取** need 个(而非固定取前 N),让所有种子都有机会露出;
|
||||
种子用户名留空则随机合成(避开撞名),金额取**长尾随机**(小额居多、偶尔大额,更像真实分布)。
|
||||
真实 + 种子仍不满 limit → 内置合成条**补满**,保证轮播既不空也不稀疏。
|
||||
@@ -235,19 +251,13 @@ def get_feed(db: Session, limit: int = 8, mode: str | None = None) -> list[dict]
|
||||
items: list[dict] = []
|
||||
used_names: set[str] = set()
|
||||
|
||||
# 真实条(mixed / real):取较多近期记录(带 ~30s 缓存)后按 user 去重;金额超上限的异常值已在查询剔除。
|
||||
# 真实条(mixed / real):**不按 user 去重**——打乱 + 去连簇(相邻尽量不同用户、减少单人连刷)后取前
|
||||
# limit;金额超上限的异常值已在查询剔除。同一用户可多条露出,但被打散、尽量不连续。
|
||||
if mode != "seed":
|
||||
rows = _recent_real_rows(db)
|
||||
seen_users: set[int] = set()
|
||||
for uid, sc, nick in rows:
|
||||
if uid in seen_users:
|
||||
continue
|
||||
seen_users.add(uid)
|
||||
for uid, sc, nick in _shuffle_declustered(_recent_real_rows(db))[:limit]:
|
||||
name = _mask_real(nick, uid)
|
||||
used_names.add(name) # 真实名按昵称/id 稳定;偶发撞名可接受
|
||||
used_names.add(name) # 同一用户可多条,名字重复无害(used_names 仅供种子避重)
|
||||
items.append({"masked_user": name, "saved_amount_cents": int(sc)})
|
||||
if len(items) >= limit:
|
||||
break
|
||||
|
||||
# 种子补位 + 合成兜底(mixed / seed):mixed 下补真实不足的部分,seed 下全量用种子/合成。
|
||||
# real 模式**跳过**——只出真实,不掺任何假数据(真实不足则少于 limit,为 0 时返回空)。
|
||||
@@ -300,6 +310,78 @@ def get_feed(db: Session, limit: int = 8, mode: str | None = None) -> list[dict]
|
||||
return items
|
||||
|
||||
|
||||
# ===== admin 侧:分页浏览「当前模式下可展示的记录」(审核用,不去重) =====
|
||||
_REAL_BROWSE_CAP = 1000 # 真实记录一次最多纳入这么多去洗牌+分页(足够审核;防超大库全量洗牌)
|
||||
_BROWSE_SEED = 20260707 # 固定洗牌种子:同一批数据下排列恒定 → 翻页稳定、能翻遍全部
|
||||
|
||||
|
||||
def _seed_browse_row(seed: OpsMarqueeSeed) -> dict:
|
||||
"""把一条种子按其「生成逻辑」**确定性**生成一行浏览项(名字/金额按 seed.id 派生固定种子 → 翻页稳定)。
|
||||
名字:运营手填的非模板名原样用,否则本地合成;金额:区间内确定性长尾取值。user_id=0(种子无真实用户)。"""
|
||||
r = random.Random(_BROWSE_SEED * 1_000_003 + int(seed.id))
|
||||
fixed = (seed.masked_user or "").strip()
|
||||
name = fixed if (fixed and not fixed.startswith("用户****")) else _synth_name(r)
|
||||
lo = max(0, int(seed.min_cents))
|
||||
hi = max(lo, int(seed.max_cents))
|
||||
amt = lo if hi <= lo else lo + int(round((hi - lo) * (r.random() ** 2.2)))
|
||||
return {"masked_user": name, "saved_amount_cents": amt, "created_at": "", "user_id": 0}
|
||||
|
||||
|
||||
def list_real_records(
|
||||
db: Session, mode: str | None = None, offset: int = 0, limit: int = 8
|
||||
) -> tuple[list[dict], int]:
|
||||
"""分页浏览「**当前模式**下可在 app 轮播展示的全部记录」,供 admin 逐页审核(**不去重**):
|
||||
- 只真实:全部 success+省>0 的真实记录;
|
||||
- 只种子:每条启用种子按生成逻辑各出一行;
|
||||
- 混播:真实 + 种子 全部合在一起。
|
||||
与 app 轮播同口径:先洗牌 + 去连簇(相邻尽量不同 user;种子各自独立、不算同 user);**固定种子** →
|
||||
同批数据下排列恒定,翻页不跳、能翻遍全部。返回 (items, total);item.user_id=0 表示种子。"""
|
||||
mode = mode if mode in FEED_MODES else get_feed_mode(db)
|
||||
# pool: [(cluster_key, item)];cluster_key 供去连簇——真实=user_id、种子=各自唯一负数(互不聚簇)
|
||||
pool: list[tuple[int, dict]] = []
|
||||
if mode != "seed":
|
||||
rows = db.execute(
|
||||
select(
|
||||
ComparisonRecord.user_id,
|
||||
ComparisonRecord.saved_amount_cents,
|
||||
User.nickname,
|
||||
ComparisonRecord.created_at,
|
||||
)
|
||||
.join(User, User.id == ComparisonRecord.user_id)
|
||||
.where(
|
||||
ComparisonRecord.status == "success",
|
||||
ComparisonRecord.saved_amount_cents > 0,
|
||||
ComparisonRecord.saved_amount_cents <= _REAL_MAX_CENTS,
|
||||
)
|
||||
.order_by(ComparisonRecord.created_at.desc())
|
||||
.limit(_REAL_BROWSE_CAP)
|
||||
).all()
|
||||
for uid, sc, nick, ca in rows:
|
||||
pool.append((
|
||||
int(uid),
|
||||
{
|
||||
"masked_user": _mask_real(nick, int(uid)),
|
||||
"saved_amount_cents": int(sc),
|
||||
"created_at": str(ca)[:16] if ca is not None else "",
|
||||
"user_id": int(uid),
|
||||
},
|
||||
))
|
||||
if mode != "real":
|
||||
seeds = (
|
||||
db.execute(select(OpsMarqueeSeed).where(OpsMarqueeSeed.enabled.is_(True)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for i, s in enumerate(seeds):
|
||||
pool.append((-(i + 1), _seed_browse_row(s))) # 每个种子唯一 key → 互不聚簇
|
||||
total = len(pool)
|
||||
# 每次用同一固定种子新建 Random → 同批数据排列恒定(翻页稳定);同时相邻尽量不同 user。
|
||||
ordered = _shuffle_declustered(pool, random.Random(_BROWSE_SEED))
|
||||
off = max(0, offset)
|
||||
items = [item for _key, item in ordered[off : off + limit]]
|
||||
return items, total
|
||||
|
||||
|
||||
# ===== 运营侧:种子 CRUD =====
|
||||
def list_seeds(db: Session) -> list[OpsMarqueeSeed]:
|
||||
return list(
|
||||
|
||||
+101
-1
@@ -11,7 +11,7 @@ import unicodedata
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -67,6 +67,10 @@ class WithdrawTooFrequentError(Exception):
|
||||
"""提现申请过于频繁,或已有未完成提现单。"""
|
||||
|
||||
|
||||
class WithdrawTierUnavailableError(Exception):
|
||||
"""该档位今日不可提:次数已满,或今天已选了其他额度(7-9 福利页档位规则)。"""
|
||||
|
||||
|
||||
class WithdrawTransferError(Exception):
|
||||
"""调用微信转账失败(已退回余额)。"""
|
||||
|
||||
@@ -605,6 +609,89 @@ def _settle_after_ambiguous(db: Session, order: WithdrawOrder, reason: str) -> N
|
||||
db.commit()
|
||||
|
||||
|
||||
def _beijing_today_start_utc() -> datetime:
|
||||
"""北京时今日 0 点(转 UTC)。WithdrawOrder.created_at 是 func.now()(UTC)存储,
|
||||
比较时统一转 UTC,与 admin 看板 today_start 同口径(admin/repositories/queries.py)。"""
|
||||
return (
|
||||
datetime.now(rewards.CN_TZ)
|
||||
.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
.astimezone(timezone.utc)
|
||||
)
|
||||
|
||||
|
||||
def withdraw_tier_states(db: Session, user_id: int, source: str = "coin_cash") -> list[dict]:
|
||||
"""福利页(coin_cash)提现档位的可提现状态。withdraw-info 下发与 create_withdraw 校验共用此口径。
|
||||
|
||||
规则(2026-07-09 拍板,7-9提现ui对齐):
|
||||
- 新人档(0.1/0.3):账号历史一次性——只要发起过(**任意状态**,含被拒/失败,"发起就算")
|
||||
即视为已用,直接**从返回列表消失**;两档各自独立互不影响,不参与"每日选一个额度"互斥。
|
||||
- 常规档(0.5×3 / 10×1 / 20×1):按北京日计次,"发起就算占用"(当天创建的单不论最终状态
|
||||
都计入,被拒/失败不退当天名额);三档每天只能选一个,选定后其余两档当天 other_tier_selected。
|
||||
- invite_cash 本轮无档位概念 → 返回空列表(邀请页客户端仍用本地写死档位,行为不变)。
|
||||
余额是否足够由客户端本地判断(余额随兑换实时变化,不在此快照)。
|
||||
"""
|
||||
if source != "coin_cash":
|
||||
return []
|
||||
tiers = rewards.WITHDRAW_TIERS_COIN_CASH
|
||||
amounts = [t.amount_cents for t in tiers]
|
||||
newbie_amounts = [t.amount_cents for t in tiers if t.is_newbie]
|
||||
# 新人档历史是否用过:任意时间、任意状态("发起就算")
|
||||
used_newbie: set[int] = set(
|
||||
db.execute(
|
||||
select(WithdrawOrder.amount_cents)
|
||||
.distinct()
|
||||
.where(
|
||||
WithdrawOrder.user_id == user_id,
|
||||
WithdrawOrder.source == "coin_cash",
|
||||
WithdrawOrder.amount_cents.in_(newbie_amounts),
|
||||
)
|
||||
).scalars()
|
||||
) if newbie_amounts else set()
|
||||
# 今日(北京日)每档已发起次数(任意状态)
|
||||
today_counts: dict[int, int] = {
|
||||
int(amount): int(cnt)
|
||||
for amount, cnt in db.execute(
|
||||
select(WithdrawOrder.amount_cents, func.count(WithdrawOrder.id))
|
||||
.where(
|
||||
WithdrawOrder.user_id == user_id,
|
||||
WithdrawOrder.source == "coin_cash",
|
||||
WithdrawOrder.amount_cents.in_(amounts),
|
||||
WithdrawOrder.created_at >= _beijing_today_start_utc(),
|
||||
)
|
||||
.group_by(WithdrawOrder.amount_cents)
|
||||
)
|
||||
}
|
||||
# "每日选一个额度":今天发起过的常规档(新人档不算)
|
||||
selected_regular = next(
|
||||
(t.amount_cents for t in tiers if not t.is_newbie and today_counts.get(t.amount_cents, 0) > 0),
|
||||
None,
|
||||
)
|
||||
out: list[dict] = []
|
||||
for t in tiers:
|
||||
if t.is_newbie:
|
||||
if t.amount_cents in used_newbie:
|
||||
continue # 用过即消失,不再下发
|
||||
out.append({
|
||||
"amount_cents": t.amount_cents, "label": t.label, "badge": t.badge,
|
||||
"is_newbie": True, "available": True, "disabled_reason": None,
|
||||
"remaining_today": 1,
|
||||
})
|
||||
continue
|
||||
used = today_counts.get(t.amount_cents, 0)
|
||||
if selected_regular is not None and selected_regular != t.amount_cents:
|
||||
available, reason, remaining = False, "other_tier_selected", 0
|
||||
elif used >= t.daily_limit:
|
||||
available, reason, remaining = False, "quota_exhausted", 0
|
||||
else:
|
||||
available, reason, remaining = True, None, t.daily_limit - used
|
||||
out.append({
|
||||
"amount_cents": t.amount_cents, "label": t.label, "badge": t.badge,
|
||||
"is_newbie": False, "available": available, "disabled_reason": reason,
|
||||
"remaining_today": remaining,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def create_withdraw(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
@@ -660,6 +747,19 @@ def create_withdraw(
|
||||
if active_order_id is not None:
|
||||
raise WithdrawTooFrequentError
|
||||
|
||||
# 福利页档位闸(7-9):coin_cash 只能提预设档位,且该档今日可提(服务端权威口径,防绕过
|
||||
# 客户端刷)。放在幂等返回/在途互斥之后:同号重试仍原样返回旧单,不被档位闸误杀。
|
||||
# allow_sub_min(0.01 调试直发)保持原样放行,不受档位约束;invite_cash 本轮无档位概念不校验。
|
||||
if source == "coin_cash" and not allow_sub_min:
|
||||
tier_state = next(
|
||||
(t for t in withdraw_tier_states(db, user_id, source) if t["amount_cents"] == amount_cents),
|
||||
None,
|
||||
)
|
||||
if tier_state is None: # 非预设档位金额,或新人档已用过(已从列表消失)
|
||||
raise InvalidWithdrawAmountError
|
||||
if not tier_state["available"]:
|
||||
raise WithdrawTierUnavailableError
|
||||
|
||||
# 账户须存在(原子扣款的 UPDATE 不会建账户)
|
||||
get_or_create_account(db, user_id, commit=True)
|
||||
|
||||
|
||||
@@ -63,6 +63,12 @@ class EcpmReportIn(BaseModel):
|
||||
description="点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页);"
|
||||
"比价与领券共用同一 Draw 代码位,需客户端在各调用点显式标注,供收益报表区分比价/领券;激励视频为空",
|
||||
)
|
||||
trace_id: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="本次比价/领券 trace_id(信息流场景带上):把这条展示收益归属到对应比价/领券,"
|
||||
"供领券数据/比价记录看板聚合本场广告收益;激励视频/福利为空",
|
||||
)
|
||||
app_env: str | None = Field(
|
||||
None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""自报计数上报 schema。字段名对齐客户端 payload(snake_case),累计值语义。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SelfStatEventIn(BaseModel):
|
||||
event: str = Field(max_length=64)
|
||||
attempted: int = 0
|
||||
drop_capture: int = 0
|
||||
delivered: int = 0
|
||||
drop_undelivered: int = 0
|
||||
|
||||
|
||||
class SelfStatBatchIn(BaseModel):
|
||||
device_id: str = Field(max_length=64)
|
||||
epoch_id: str = Field(max_length=64)
|
||||
sent_at: int | None = None
|
||||
app_ver: str | None = Field(default=None, max_length=32)
|
||||
oem: str | None = Field(default=None, max_length=32)
|
||||
os: str | None = Field(default=None, max_length=32)
|
||||
batches_attempted: int = 0
|
||||
batches_ok: int = 0
|
||||
batches_fail: int = 0
|
||||
retries: int = 0
|
||||
queue_depth: int = 0
|
||||
# 允许空列表:某次快照只上报设备级计数(batches_*/retries/queue_depth)、无 event 细分时也合法
|
||||
# (与 AnalyticsBatchIn 的 min_length=1 有意不同——那是行为事件、必须至少一条)。
|
||||
events: list[SelfStatEventIn] = Field(default_factory=list, max_length=200)
|
||||
|
||||
|
||||
class SelfStatIngestOut(BaseModel):
|
||||
ok: bool = True
|
||||
snapshot_id: int
|
||||
@@ -75,6 +75,21 @@ class ExchangeResultOut(BaseModel):
|
||||
|
||||
# ===== 提现(现金 → 微信零钱) =====
|
||||
|
||||
class WithdrawTierOut(BaseModel):
|
||||
"""提现档位(福利页 coin_cash;7-9 对齐原型)。served by rewards.WITHDRAW_TIERS_COIN_CASH。"""
|
||||
|
||||
amount_cents: int = Field(..., description="档位金额(分)")
|
||||
label: str = Field(..., description="档位方块展示文案,如 0.1 / 10")
|
||||
badge: str | None = Field(None, description="角标文案(如 新人福利);无则空")
|
||||
is_newbie: bool = Field(False, description="新人档:历史一次性,用过后不再下发;免广告直提")
|
||||
available: bool = Field(True, description="当前是否可提(次数/选一额度口径;余额由客户端自判)")
|
||||
disabled_reason: str | None = Field(
|
||||
None,
|
||||
description="不可提原因:quota_exhausted(今日次数满) / other_tier_selected(今日已选其他额度)",
|
||||
)
|
||||
remaining_today: int = Field(0, description="今日剩余可提次数")
|
||||
|
||||
|
||||
class WithdrawInfoOut(BaseModel):
|
||||
min_cents: int = Field(..., description="单次最低提现(分)")
|
||||
max_cents: int = Field(..., description="单次最高提现(分)")
|
||||
@@ -84,6 +99,10 @@ class WithdrawInfoOut(BaseModel):
|
||||
transfer_auth_enabled: bool = Field(
|
||||
False, description="是否已开启免确认到账(开启后提现免跳微信确认,直接到账)"
|
||||
)
|
||||
tiers: list[WithdrawTierOut] = Field(
|
||||
default_factory=list,
|
||||
description="提现档位(source=coin_cash 下发;invite_cash 为空,客户端走旧逻辑)",
|
||||
)
|
||||
|
||||
|
||||
# ===== 免确认收款授权(用户授权免确认模式)=====
|
||||
|
||||
+40
-44
@@ -3,7 +3,7 @@
|
||||
> Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770`
|
||||
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**
|
||||
> 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer <access_token>`
|
||||
> 最后更新:2026-07-03(补全缺失文档:ad/watch-report, wallet/transfer-auth 族, coupon/session+stats+completed-today+prompt 族, invite 族, user/onboarding, platform/flags+ad-config+app-version, intent/step+precoupon/step, analytics/events, order/report, report 族, feedback/config+records, trace/finalize。文档移至分类子目录,新增 mock 入参/出参示例)
|
||||
> 最后更新:2026-07-09(① 比价透传改「软鉴权 + trace_id 签发 + harvest 落库」(#112 尾声帧 `trace/epilogue` 一并补录);② 新端点:`user/onboarding/reset`(#114)、`GET /internal/launch-confirm-samples`(#91);③ 参数更新:提现族 `source` 分账(#82/#121)、`wallet/account` 邀请奖励金余额、美团 feed/top-sales 按城市过滤(#116)、admin 调现金 `account` 目标账户(#95);④ **Admin 索引补全到当前全量**:新家族 roles(#117/#126)/coupon-data(#99)/device-liveness(#80)/event-logs(#83)/price-reports(#94)/CPS 运营台/提现审核族,及 feedbacks 采纳拒绝(#94/#105)、marquee 模式与真实条浏览(#122/#123)等。上一次 2026-07-03)
|
||||
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
|
||||
|
||||
---
|
||||
@@ -27,17 +27,18 @@
|
||||
| 8e | `GET /api/v1/coupon/completed-today` | 无 | [详情](./coupon/coupon-completed-today.md)(这台设备今天是否已跑完整轮领券) |
|
||||
| 8f | `POST /api/v1/coupon/completed-today/reset` | 无 | [详情](./coupon/coupon-completed-today.md)(重置今日已完成,开发用) |
|
||||
| 8g | `GET /api/v1/coupon/stats` | Bearer | [详情](./coupon/coupon-stats.md)(累计领券数,「我的」页战绩卡) |
|
||||
| 8h | `POST /api/v1/coupon/session` | 无 | [详情](./coupon/coupon/coupon-session.md)(领券流水上报,admin 看板数据源) |
|
||||
| 8h | `POST /api/v1/coupon/session` | 无 | [详情](./coupon/coupon-session.md)(领券流水上报,admin 看板数据源) |
|
||||
| 9 | `POST /api/v1/meituan/coupons` | 无 | [详情](./meituan/meituan-coupons.md) |
|
||||
| 10 | `POST /api/v1/meituan/feed` | 无 | [详情](./meituan/meituan-feed.md) |
|
||||
| 10 | `POST /api/v1/meituan/feed` | 无 | [详情](./meituan/meituan-feed.md)(`rec` tab 离线库 + **按城市过滤** #116) |
|
||||
| 11 | `POST /api/v1/meituan/referral-link` | 无 | [详情](./meituan/meituan-referral-link.md) |
|
||||
| 11a | `POST /api/v1/meituan/top-sales` | 无 | [详情](./meituan/meituan-top-sales.md)(销量榜:离线库 `meituan_coupon` 按销量降序 + 跨源去重,不实时打美团) |
|
||||
| **比价透传**(前缀 `/api/v1`,外卖 MVP;与 `coupon/step` 同为透传 pricebot-backend;下按 Phase 流程列,均不鉴权) |||
|
||||
| 12 | `POST /api/v1/intent/recognize` | 无 | [详情](./intent/compare-intent-recognize.md)(Phase 1 意图识别,单次,多数源) |
|
||||
| 12a | `POST /api/v1/intent/precoupon/step` | 无 | [详情](./intent/intent-step.md)(Phase 0 意图识别前先用券,仅美团源) |
|
||||
| 12b | `POST /api/v1/intent/step` | 无 | [详情](./intent/intent-step.md)(Phase 1 多帧意图识别,仅淘宝源,循环到 done) |
|
||||
| 13 | `POST /api/v1/price/step` | 无 | [详情](./intent/compare-price-step.md)(Phase 2 步进) |
|
||||
| 13a | `POST /api/v1/trace/finalize` | 无 | [详情](./other/trace-finalize.md)(比价 trace 收尾上云,终止/未识别拿 trace_url) |
|
||||
| 11a | `POST /api/v1/meituan/top-sales` | 无 | [详情](./meituan/meituan-top-sales.md)(同城销量榜:离线库按销量降序 + 跨源去重 + 城市过滤 #116,不实时打美团) |
|
||||
| **比价透传**(前缀 `/api/v1`,透传 pricebot-backend;**软鉴权 OptionalUser** + 首帧签发 trace_id + harvest 落 `comparison_record`,2026-07 起不再是纯透传) |||
|
||||
| 12 | `POST /api/v1/intent/recognize` | 软 | [详情](./intent/compare-intent-recognize.md)(Phase 1 意图识别,单次,多数源;mint 帧建 running 行) |
|
||||
| 12a | `POST /api/v1/intent/precoupon/step` | 软 | [详情](./intent/intent-step.md)(Phase 0 意图识别前先用券,仅美团源) |
|
||||
| 12b | `POST /api/v1/intent/step` | 软 | [详情](./intent/intent-step.md)(Phase 1 多帧意图识别,仅淘宝源,循环到 done) |
|
||||
| 13 | `POST /api/v1/price/step` | 软 | [详情](./intent/compare-price-step.md)(Phase 2 步进;done 帧 harvest 写终态) |
|
||||
| 13a | `POST /api/v1/trace/finalize` | 软 | [详情](./other/trace-finalize.md)(比价 trace 收尾上云 + 夭折落库,终止/未识别拿 trace_url) |
|
||||
| 13b | `POST /api/v1/trace/epilogue` | 软 | [详情](./other/trace-finalize.md)(结果页尾声帧:App 结果页截图入 trace,纯透传不落库,#112) |
|
||||
| **比价记录**(前缀 `/api/v1/compare`;按用户落库,**鉴权**,区别于上面不鉴权的透传) |||
|
||||
| 12a | `POST /api/v1/compare/record` | Bearer | [详情](./compare/compare-record-report.md) |
|
||||
| 12b | `GET /api/v1/compare/records` | Bearer | [详情](./compare/compare-records.md) |
|
||||
@@ -54,7 +55,7 @@
|
||||
| **上报更低价**(前缀 `/api/v1/report`;众包纠偏,人工审核发奖) |||
|
||||
| R1 | `POST /api/v1/report` | Bearer | [详情](./other/report-submit.md)(提交上报更低价,multipart:比价记录ID+平台+价格+截图1-4张) |
|
||||
| R2 | `GET /api/v1/report/records` | Bearer | [详情](./other/report-records.md)(上报记录列表,?status=pending/approved/rejected 可选筛选) |
|
||||
| **好友邀请**(前缀 `/api/v1/invite`;注册即生效,双方各发 1 万金币) |||
|
||||
| **好友邀请**(前缀 `/api/v1/invite`;绑定注册即生效但**不发奖**,#113 起好友「比价并下单」才给邀请人发**邀请奖励金**,经 `POST /order/report` 触发) |||
|
||||
| I1 | `GET /api/v1/invite/me` | Bearer | [详情](./invite/invite-me.md)(我的邀请码+分享链接+已邀人数/已得金币) |
|
||||
| I2 | `GET /api/v1/invite/invitees` | Bearer | [详情](./invite/invite-invitees.md)(我邀请的人列表,limit/offset 分页) |
|
||||
| I3 | `POST /api/v1/invite/landing-track` | 无 | [详情](./invite/invite-bind.md)(落地页 dl.html 访问上报指纹,剪贴板归因兜底;浏览器无 token) |
|
||||
@@ -68,9 +69,9 @@
|
||||
| 19 | `POST /api/v1/wallet/bind-wechat` | Bearer | [详情](./wallet/wallet-bind-wechat.md) |
|
||||
| 20 | `POST /api/v1/wallet/unbind-wechat` | Bearer | [详情](./wallet/wallet-unbind-wechat.md) |
|
||||
| 21 | `GET /api/v1/wallet/withdraw-info` | Bearer | [详情](./wallet/wallet-withdraw-info.md) |
|
||||
| 22 | `POST /api/v1/wallet/withdraw` | Bearer | [详情](./wallet/wallet-withdraw.md) |
|
||||
| 22 | `POST /api/v1/wallet/withdraw` | Bearer | [详情](./wallet/wallet-withdraw.md)(`source` 分账:coin_cash / invite_cash,#121) |
|
||||
| 23 | `GET /api/v1/wallet/withdraw/status` | Bearer | [详情](./wallet/wallet-withdraw-status.md) |
|
||||
| 24 | `GET /api/v1/wallet/withdraw-orders` | Bearer | [详情](./wallet/wallet-withdraw-orders.md) |
|
||||
| 24 | `GET /api/v1/wallet/withdraw-orders` | Bearer | [详情](./wallet/wallet-withdraw-orders.md)(可按 `source` 过滤) |
|
||||
| 24a | `POST /api/v1/wallet/transfer-auth` | Bearer | [详情](./wallet/wallet-transfer-auth.md)(开启免确认到账,申请授权,返回拉起微信授权页的 package) |
|
||||
| 24b | `GET /api/v1/wallet/transfer-auth/status` | Bearer | [详情](./wallet/wallet-transfer-auth.md)(查免确认授权状态,从微信授权页返回后轮询) |
|
||||
| 24c | `POST /api/v1/wallet/transfer-auth/close` | Bearer | [详情](./wallet/wallet-transfer-auth.md)(关闭免确认到账,解除授权) |
|
||||
@@ -99,6 +100,7 @@
|
||||
| 36 | `POST /api/v1/user/avatar` | Bearer | [详情](./user/user-avatar.md) |
|
||||
| 36a | `POST /api/v1/user/onboarding/complete` | Bearer | [详情](./user/user-onboarding.md)(标记新手引导完成,按 账号+device_id 幂等,跨卸载重装持久) |
|
||||
| 36b | `GET /api/v1/user/onboarding/status` | Bearer | [详情](./user/user-onboarding.md)(查该 账号+设备 是否走过引导,运营在 admin 删记录即触发重走) |
|
||||
| 36c | `POST /api/v1/user/onboarding/reset` | Bearer | [详情](./user/user-onboarding.md)(重置本设备引导标记,下次登录重走,#114) |
|
||||
| 37 | `DELETE /api/v1/user` | Bearer | [详情](./user/user-delete.md) |
|
||||
| **帮助与反馈**(前缀 `/api/v1/feedback`) |||
|
||||
| 38 | `POST /api/v1/feedback` | Bearer | [详情](./other/feedback.md) |
|
||||
@@ -127,43 +129,37 @@
|
||||
| N4 | `POST /internal/store-mapping/invalidate` | 内部密钥 | [详情](./internal/internal.md)(标记某平台 shopId 缓存 deeplink 失效) |
|
||||
| N5 | `POST /internal/launch-confirm-sample` | 内部密钥 | [详情](./internal/internal.md)(启动确认窗兜底样本落 `launch_confirm_sample`) |
|
||||
| N6 | `POST /internal/app-version` | 内部密钥 | [详情](./internal/internal.md)(发布流程写最新 App 版本,落 `app_config`) |
|
||||
| N7 | `GET /internal/launch-confirm-samples` | 内部密钥 | [详情](./internal/internal.md)(样本列表,供 pricebot distill 脚本聚合沉淀回静态规则,#91) |
|
||||
| **静态资源**(StaticFiles 挂载,见下方 `/media` 静态服务) |||
|
||||
| - | `GET /media/avatars/<file>` | 无 | 用户头像;返回二进制图片 |
|
||||
| - | `GET /media/feedback/<file>` | 无 | 反馈截图;返回二进制图片 |
|
||||
| **运营后台 Admin**(独立子应用 `app/admin/`,前缀 `/admin/api`,独立进程 + 独立 admin JWT。鉴权列:`admin`=任意已登录管理员,`operator`/`finance`/`super_admin`=需对应角色(`super_admin` 恒通过)) |||
|
||||
| A1 | `POST /admin/api/auth/login` | 无 | [详情](./admin/auth/admin-auth-login.md) |
|
||||
| A2 | `GET /admin/api/auth/me` | admin | [详情](./admin/auth/admin-auth-me.md) |
|
||||
| A3 | `GET /admin/api/stats/overview` | admin | [详情](./admin/admin-stats-overview.md) |
|
||||
| A4 | `GET /admin/api/users` | admin | [详情](./admin/users/admin-users-list.md) |
|
||||
| A5 | `GET /admin/api/users/{user_id}` | admin | [详情](./admin/users/admin-user-detail.md) |
|
||||
| A6 | `POST /admin/api/users/{user_id}/status` | operator | [详情](./admin/users/admin-user-status.md) |
|
||||
| A7 | `POST /admin/api/users/{user_id}/coins` | finance | [详情](./admin/users/admin-user-coins.md) |
|
||||
| A8 | `POST /admin/api/users/{user_id}/cash` | finance | [详情](./admin/users/admin-user-cash.md) |
|
||||
| A9 | `GET /admin/api/wallet/coin-transactions` | admin | [详情](./admin/wallet/admin-wallet-coin-transactions.md) |
|
||||
| A10 | `GET /admin/api/wallet/cash-transactions` | admin | [详情](./admin/wallet/admin-wallet-cash-transactions.md) |
|
||||
| A11 | `GET /admin/api/withdraws` | admin | [详情](./admin/withdraws/admin-withdraws-list.md) |
|
||||
| A12 | `POST /admin/api/withdraws/reconcile` | finance | [详情](./admin/withdraws/admin-withdraw-reconcile.md) |
|
||||
| A13 | `POST /admin/api/withdraws/{out_bill_no}/refresh` | finance | [详情](./admin/withdraws/admin-withdraw-refresh.md) |
|
||||
| A14 | `GET /admin/api/feedbacks` | admin | [详情](./admin/feedbacks/admin-feedbacks-list.md) |
|
||||
| A15 | `POST /admin/api/feedbacks/{feedback_id}/handle` | operator | [详情](./admin/feedbacks/admin-feedback-handle.md) |
|
||||
| A16 | `GET /admin/api/admins` | super_admin | [详情](./admin/admins/admin-admins-list.md) |
|
||||
| A17 | `POST /admin/api/admins` | super_admin | [详情](./admin/admins/admin-admin-create.md) |
|
||||
| A18 | `PATCH /admin/api/admins/{admin_id}` | super_admin | [详情](./admin/admins/admin-admin-update.md) |
|
||||
| A19 | `GET /admin/api/audit-logs` | admin | [详情](./admin/admin-audit-logs.md) |
|
||||
| A20 | `GET /admin/api/dashboard-display` | admin | [详情](./admin/admin-dashboard-display.md) |
|
||||
| A21 | `PATCH /admin/api/dashboard-display/{metric}` | operator | [详情](./admin/admin-dashboard-display.md) |
|
||||
| A22 | `GET /admin/api/marquee-seeds` | admin | [详情](./admin/admin-marquee-seeds.md) |
|
||||
| A23 | `POST /admin/api/marquee-seeds` | operator | [详情](./admin/admin-marquee-seeds.md) |
|
||||
| A24 | `PATCH /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin/admin-marquee-seeds.md) |
|
||||
| A25 | `DELETE /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin/admin-marquee-seeds.md) |
|
||||
| A26 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin/admin-marquee-seeds.md) |
|
||||
| A27 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./admin/admin-marquee-seeds.md) |
|
||||
| A28 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin/ad/admin-ad-coin-audit.md)(看广告金币公式复算对账,只读) |
|
||||
| A29 | `GET /admin/api/ad-revenue-report` | admin | [详情](./admin/ad/admin-ad-revenue-report.md)(广告收益报表:按用户/日期/类型/应用/代码位 聚合 条数/收益/金币,只读) |
|
||||
| **运营后台 Admin**(独立子应用 `app/admin/`,前缀 `/admin/api`,独立进程 + 独立 admin JWT。鉴权列:`admin`=任意已登录管理员,`operator`/`finance`/`super_admin`=需对应角色;#117 起可见页由 [admin_role](../database/admin_role.md) 数据驱动,`super_admin` 恒通过) |||
|
||||
| A1 | `POST /admin/api/auth/login` · `GET /auth/me` | 无 / admin | [详情](./admin/auth/admin-auth-login.md) / [me](./admin/auth/admin-auth-me.md)(me 返回有效可见页 `pages`) |
|
||||
| A2 | `GET /admin/api/stats/overview` | admin | [详情](./admin/admin-stats-overview.md)(大盘核心指标;#103 按 trace 聚合 + 京东收益 #90 + feed_scene 口径 #125) |
|
||||
| A3 | `GET /admin/api/event-logs` | admin | [详情](./admin/admin-event-logs.md)(埋点日志检索,#83) |
|
||||
| **A·用户**:`GET /users`(筛选排序分页)、`GET /users/{id}`(360 详情)、`GET /{id}/reward-stats` + `GET /{id}/coin-records`(提现详情联查)、`POST /{id}/status`(封禁)、`POST /{id}/debug-trace`(调试链接权限)、`POST /{id}/coins`、`POST /{id}/cash`(#95 `account` 目标账户) ||| [列表](./admin/users/admin-users-list.md) / [详情](./admin/users/admin-user-detail.md) / [状态+debug-trace](./admin/users/admin-user-status.md) / [金币](./admin/users/admin-user-coins.md) / [现金](./admin/users/admin-user-cash.md) |
|
||||
| A4 | `GET /admin/api/wallet/coin-transactions` / `cash-transactions` | admin | [金币](./admin/wallet/admin-wallet-coin-transactions.md) / [现金](./admin/wallet/admin-wallet-cash-transactions.md) |
|
||||
| **A·提现审核台**:`GET /withdraws`(列表)、`/summary`、`/health-check`(finance)、`/ledger-check`(#121 分账对账)、`/{out_bill_no}`(详情)、`POST /reconcile`、单笔 `refresh`/`approve`/`reject`、批量 `bulk/refresh`/`bulk/approve`/`bulk/reject` ||| [列表](./admin/withdraws/admin-withdraws-list.md) / [审核族](./admin/withdraws/admin-withdraw-review.md) / [对账](./admin/withdraws/admin-withdraw-reconcile.md) / [查单](./admin/withdraws/admin-withdraw-refresh.md) |
|
||||
| **A·反馈**:`GET /feedbacks`、`/summary`、`POST /{id}/approve`(采纳发币 #94)、`/{id}/reject`、`/{id}/handle` ||| [列表](./admin/feedbacks/admin-feedbacks-list.md) / [审核族](./admin/feedbacks/admin-feedback-handle.md) |
|
||||
| A5 | `GET`/`PATCH` `/admin/api/feedback-config`,`POST`/`DELETE` `…/image` | operator | 反馈页「加群二维码」卡配置(admin 侧;C 端读见 38a)(无单独文档,见 `app/admin/routers/feedback_qr.py`) |
|
||||
| **A·上报更低价**:`GET /price-reports`、`/summary`、`POST /{id}/approve|reject`(#94) ||| [审核族](./admin/admin-price-reports.md) |
|
||||
| A6 | `GET /admin/api/comparison-records`(+`/{id}` 详情) | admin | 比价记录检索(按 user/phone/**店与商品名模糊搜** #117 筛;详情含 LLM 调用明细)(无单独文档,见 `app/admin/routers/comparison.py`) |
|
||||
| A7 | `GET /admin/api/coupon-data`(+`/user-records`) | admin | [详情](./admin/admin-coupon-data.md)(领券数据看板,#99) |
|
||||
| A8 | `GET /admin/api/device-liveness`(+`/stats`) | admin | [详情](./admin/admin-device-liveness.md)(设备存活监控,#80) |
|
||||
| A9 | `GET /onboarding/devices`、`POST /devices/{id}/reset`、`POST /reset-all` | operator | 新手引导记录管理(按设备聚合/重置)(无单独文档,见 `app/admin/routers/onboarding.py`) |
|
||||
| **A·轮播**:`GET /marquee-seeds`、`/preview`、`/real-records`(#123)、`GET`/`PATCH` `/mode`(#122,模式落 `app_config`)、`POST`(+`/bulk`、`/batch-delete`、`/batch-enable`)、`PATCH`/`DELETE` `/{seed_id}` ||| [详情](./admin/admin-marquee-seeds.md) |
|
||||
| A10 | `GET / PATCH /admin/api/dashboard-display` | admin / operator | [详情](./admin/admin-dashboard-display.md)(首页三统计配置) |
|
||||
| A11 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin/ad/admin-ad-coin-audit.md)(看广告金币公式复算对账,只读) |
|
||||
| A12 | `GET /admin/api/ad-revenue-report` | admin | [详情](./admin/ad/admin-ad-revenue-report.md)(广告收益报表:分页/场景/`app_env` 筛 + **DAU/ARPU** #120;真实收益侧接穿山甲日表 #92) |
|
||||
| A13 | `GET / PATCH /admin/api/ad-config` | operator/finance | 广告配置(穿山甲 ID/验签密钥/各场景开关;C 端只读版见 40b)(无单独文档,见 `app/admin/routers/ad_config.py`) |
|
||||
| A14 | `GET /admin/api/config`、`PATCH /config/{key}` | operator/finance | 运营可配置项([app_config](../database/app_config.md):奖励常量/提现地板价等;#117 修系统配置下发)(无单独文档,见 `app/admin/routers/config.py`) |
|
||||
| **A·管理员与角色**(super_admin):`GET`/`POST` `/admins`、`PATCH`/`DELETE` `/admins/{id}`(#126 删除+`pages_override`)、`GET`/`POST` `/roles`、`GET /roles/catalog`、`PATCH`/`DELETE` `/roles/{id}`(#117/#126 自定义角色) ||| [列表](./admin/admins/admin-admins-list.md) / [建](./admin/admins/admin-admin-create.md) / [改+删](./admin/admins/admin-admin-update.md) / [角色](./admin/admin-roles.md) |
|
||||
| A15 | `GET /admin/api/audit-logs` | admin | [详情](./admin/admin-audit-logs.md) |
|
||||
| **A·CPS 运营台**:群/活动 CRUD、`POST /referral-links`、`POST /orders/reconcile`(美团+京东 #90)、`GET /orders`、`/stats`、群 `timeseries`/`daily`/`wx-users`/`day-users`(#79) ||| [详情](./admin/admin-cps.md) |
|
||||
| - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) |
|
||||
|
||||
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
|
||||
> `coupon/step` 及外卖比价的 `intent/recognize`、`intent/precoupon/step`、`intent/step`、`price/step`、`trace/finalize` 都透传到 pricebot-backend,**MVP 阶段均不鉴权**(device_id 透传,待补 JWT——见 `app/api/v1/compare.py`)。
|
||||
> `coupon/step` 透传到 pricebot-backend,**仍不鉴权**(device_id 区分设备,待补 JWT)。外卖比价透传族(`intent/*`、`price/step`、`trace/finalize|epilogue`)2026-07 起改**软鉴权 OptionalUser**:带 Bearer 则比价记录绑 `user_id`,不带也放行;并由 app-server 首帧签发 `trace_id` + harvest 落 `comparison_record`(见 `app/api/v1/compare.py` 模块注释)。
|
||||
> 福利相关业务接口(wallet/signin/tasks/savings、`ad/reward-status`、`ad/feed-reward`)均需 **Bearer**;`wallet/exchange-info` 是静态规则无鉴权;`ad/pangle-callback` 不走 JWT、靠穿山甲**验签**;`ad/test-grant` **仅本地联调**(开关控制,生产 404)。
|
||||
> 金额字段一律以**分**为单位(`*_cents`)。
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# /admin/api/coupon-data — 领券数据看板(#99)
|
||||
|
||||
> 所属:Admin 子应用(前缀 `/admin/api`) | 鉴权:admin(任意已登录管理员) | 表 [coupon_session](../../database/coupon_session.md) | [← 返回 API 索引](../README.md)
|
||||
|
||||
数据源是客户端两段上报的 `coupon_session`(一次领券任务一行:发起建行/收尾更新)。看板量化:发起数、完成率、**中途流失**(started 无终态)、平均/分位耗时、各平台耗时、机型/ROM 维度。
|
||||
|
||||
## 端点
|
||||
|
||||
| 方法 + 路径 | 说明 |
|
||||
|---|---|
|
||||
| `GET /admin/api/coupon-data` | 看板聚合:发起/完成数 + 耗时分位 + 按天趋势 + 逐条明细(按 `started_date` 区间 + `app_env` 筛,默认只看 prod 防测试数据串台) |
|
||||
| `GET /admin/api/coupon-data/user-records` | 某用户全部领券记录(用户列表点手机号抽屉:领券次数 + 记录列表) |
|
||||
|
||||
## 说明
|
||||
- 明细行 LEFT JOIN `user` 出手机号/昵称(匿名领券行 user 列为空)。
|
||||
- 「发起平台」列按 `origin_package` 区分:空=App 内首页发起,包名=从对应外卖 App 弹券引导发起。
|
||||
- 耗时口径:`elapsed_ms` 客户端全程计时(只统计 completed)。
|
||||
@@ -0,0 +1,28 @@
|
||||
# /admin/api/cps — CPS 群发联盟运营台(群/活动/短链/对账)
|
||||
|
||||
> 所属:Admin 子应用(前缀 `/admin/api/cps`) | 鉴权:读=admin,写=operator/finance(对账) | 表 [cps_group](../../database/cps_group.md) / [cps_activity](../../database/cps_activity.md) / [cps_link](../../database/cps_link.md) / [cps_click](../../database/cps_click.md) / [cps_order](../../database/cps_order.md) / [cps_wx_user](../../database/cps_wx_user.md) | [← 返回 API 索引](../README.md)
|
||||
>
|
||||
> 业务与授权流程详见 [guides/CPS发券分发与微信授权](../../guides/CPS发券分发与微信授权.md);C 端落地短链见 [cps-redirect](../other/cps-redirect.md)。
|
||||
|
||||
私域社群 CPS 的完整运营链:建群(拿 `sid`)→ 建活动(券/物料)→ 生成群发短链 `/c/{code}` → 用户点击/复制口令 → 联盟订单按 `sid` 归群对账,汇成「点击→下单→佣金」漏斗。
|
||||
|
||||
## 端点
|
||||
|
||||
| 方法 + 路径 | 说明 |
|
||||
|---|---|
|
||||
| `GET / POST /admin/api/cps/groups`,`PATCH / DELETE /groups/{id}` | 推广群 CRUD;含美团平台的群自动分配 `sid` |
|
||||
| `GET / POST /admin/api/cps/activities`,`PATCH / DELETE /activities/{id}` | 可推广活动 CRUD(美团 actId / 淘宝淘口令 / 京东链接) |
|
||||
| `POST /admin/api/cps/upload-image`、`GET /activity-images` | 活动落地页图上传 / 已有图列表(新建复用) |
|
||||
| `POST /admin/api/cps/referral-links` | 批量生成群×活动短链(美团经 sid 转链) |
|
||||
| `POST /admin/api/cps/orders/reconcile` | 拉联盟订单对账(美团 `query_order` + 京东联盟 #90,`order_id` 幂等 upsert;finance) |
|
||||
| `GET /admin/api/cps/orders` | 订单明细(游标分页,可按 sid / 状态筛) |
|
||||
| `GET /admin/api/cps/stats` | 按群对账统计(点击/订单/GMV/预估与结算佣金) |
|
||||
| `GET /admin/api/cps/groups/{id}/timeseries` | 群点击时序(天/小时级 PV/UV/复制,折线图) |
|
||||
| `GET /admin/api/cps/groups/{id}/daily` | 群每天明细大表格(点击+订单按天合并;#79 起支持按天下钻) |
|
||||
| `GET /admin/api/cps/groups/{id}/wx-users` | 群内微信用户(领券画像:头像/昵称/领券次数) |
|
||||
| `GET /admin/api/cps/groups/{id}/day-users` | 某天该群按用户的领券/点击 + 每人点过的券(#79) |
|
||||
|
||||
## 说明
|
||||
- 订单与点击**只能在群(sid)维度汇合**,无法对到单笔(联盟只回传 sid)。
|
||||
- 京东单有效性按 `jd_valid_code`,美团按 `mt_status`(4 取消/5 风控不计佣,6 结算才到账);淘宝无对账 API,对账列显示 `-`。
|
||||
- #119 修美团 `pay_time` 入库为空导致大盘时间窗漏算。
|
||||
@@ -0,0 +1,15 @@
|
||||
# /admin/api/device-liveness — 设备存活监控(#80)
|
||||
|
||||
> 所属:Admin 子应用(前缀 `/admin/api`) | 鉴权:admin | 表 [device_liveness](../../database/device_liveness.md) | [← 返回 API 索引](../README.md)
|
||||
|
||||
无障碍保护存活的后台视角:哪些设备开过保护(`ever_protected`)、现在在线还是掉线(心跳超时,#107 起阈值 1 小时)、首次开启时间(`first_protected_at`)。
|
||||
|
||||
## 端点
|
||||
|
||||
| 方法 + 路径 | 说明 |
|
||||
|---|---|
|
||||
| `GET /admin/api/device-liveness/stats` | 顶部卡片统计:设备总数 / 开过保护 / 当前在线 / 掉线数 |
|
||||
| `GET /admin/api/device-liveness` | 设备存活列表(游标分页):在线情况/设备 id/归属用户 筛选 + 排序,**默认掉线置顶**;行含最近心跳、首次开启、App 版本、push token 有无 |
|
||||
|
||||
## 说明
|
||||
- 「在线」= `last_heartbeat_at` 距今 < 超时阈值;掉线召回链路(worker 置 `kill_alert_pending` → 客户端 pull)见表文档。
|
||||
@@ -0,0 +1,15 @@
|
||||
# /admin/api/event-logs — 埋点日志(#83)
|
||||
|
||||
> 所属:Admin 子应用(前缀 `/admin/api`) | 鉴权:admin | 表 [analytics_event](../../database/analytics_event.md) | [← 返回 API 索引](../README.md)
|
||||
|
||||
客户端埋点(`POST /api/v1/analytics/events` 批量上报)的后台检索页。
|
||||
|
||||
## 端点
|
||||
|
||||
| 方法 + 路径 | 说明 |
|
||||
|---|---|
|
||||
| `GET /admin/api/event-logs` | 埋点事件列表(游标分页):可按 `event` / `device_id` / `user_id` 筛;行含事件名、props、页面、机型/系统/网络、client_ts |
|
||||
|
||||
## 说明
|
||||
- 纯只读;无聚合报表(要分析导出后自己算)。
|
||||
- 时间轴用 `client_ts`(事件真实发生时刻),入库时间受客户端攒批影响。
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 所属:Admin 组(前缀 `/admin/api/marquee-seeds`) | 鉴权:Admin Bearer(改需 operator/super) | [← 返回 API 索引](../README.md)
|
||||
|
||||
管理首页轮播「真实+种子混播」的兜底种子。种子是「生成规则」:`masked_user` 可空(空→feed 随机合成名)、金额是 `[min_cents, max_cents]` 区间(feed 每次随机取值)。用户侧 feed 见 [platform-savings-feed](./platform-savings-feed.md);表见 [ops_marquee_seed](../database/ops_marquee_seed.md)。金额单位:分(前端 ÷100 显示元)。
|
||||
管理首页轮播「真实+种子混播」的兜底种子。种子是「生成规则」:`masked_user` 可空(空→feed 随机合成名)、金额是 `[min_cents, max_cents]` 区间(feed 每次随机取值)。用户侧 feed 见 [platform-savings-feed](../savings/platform-savings-feed.md);表见 [ops_marquee_seed](../../database/ops_marquee_seed.md)。金额单位:分(前端 ÷100 显示元)。
|
||||
|
||||
## 复用结构 OpsMarqueeSeedOut
|
||||
| 字段 | 类型 | 说明 |
|
||||
@@ -19,9 +19,20 @@
|
||||
出参 `200`:`list[OpsMarqueeSeedOut]`(按 `sort_order,id`)。
|
||||
|
||||
## GET /admin/api/marquee-seeds/preview — 预览实际混播 feed
|
||||
预览客户端实际会看到的轮播(真实记录会插队、种子随机抽取 / 金额随机 / 名字合成),供运营对效果。**含随机,每次结果不同**。
|
||||
预览客户端实际会看到的轮播(真实记录会插队、种子随机抽取 / 金额随机 / 名字合成),供运营对效果。**含随机,每次结果不同**;#122 起按**当前数据源模式**实时预览(mixed/real/seed 各自的真实产出)。
|
||||
- 入参:`limit`(query,1~30,默认 8)
|
||||
- 出参 `200`:`{"items": [{masked_user, saved_amount_cents, time}]}`(条目同 [platform-savings-feed](./platform-savings-feed.md))
|
||||
- 出参 `200`:`{"items": [{masked_user, saved_amount_cents, time}]}`(条目同 [platform-savings-feed](../savings/platform-savings-feed.md))
|
||||
|
||||
## GET /admin/api/marquee-seeds/real-records — 分页浏览当前模式下可展示的真实记录(#123)
|
||||
审核用:看「真实条」到底会拿哪些 `comparison_record` 上轮播(真实条**不按用户去重**,打乱+去连簇后混播;默认昵称归「无昵称」脱敏档,#122)。
|
||||
- 入参:`limit` / `cursor`(游标分页)
|
||||
- 出参 `200`:`{"items": [...], "next_cursor": int|null}`
|
||||
|
||||
## GET /admin/api/marquee-seeds/mode — 首页轮播数据源模式
|
||||
出参:`{"mode": "mixed" | "real" | "seed"}`(混播 / 只真实 / 只种子)。
|
||||
|
||||
## PATCH /admin/api/marquee-seeds/mode — 改数据源模式(带审计)
|
||||
- 入参:`{"mode": "mixed" | "real" | "seed"}`;operator 起。改动客户端重拉 feed 生效。
|
||||
|
||||
## POST /admin/api/marquee-seeds — 新增(带审计)
|
||||
入参 `OpsMarqueeSeedCreate`:`masked_user`(可选,空 / 不传 → 随机合成)、`min_cents`(必填,≥0)、`max_cents`(必填,≥min,≤1000 元)、`enabled`(默认 true)、`sort_order`(默认 0)。出参:新建的 `OpsMarqueeSeedOut`。`400`=金额非法。
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# /admin/api/price-reports — 上报更低价审核(#94)
|
||||
|
||||
> 所属:Admin 子应用(前缀 `/admin/api/price-reports`) | 鉴权:读=admin,审=operator | 表 [price_report](../../database/price_report.md) | [← 返回 API 索引](../README.md)
|
||||
>
|
||||
> C 端提交/查询见 [report-submit](../other/report-submit.md) / [report-records](../other/report-records.md)。
|
||||
|
||||
用户众包「上报更低价」的人工审核台:审截图与价格,通过发固定金币。
|
||||
|
||||
## 端点
|
||||
|
||||
| 方法 + 路径 | 说明 |
|
||||
|---|---|
|
||||
| `GET /admin/api/price-reports` | 上报列表(状态筛选 + 游标分页,含截图、关联比价记录快照) |
|
||||
| `GET /admin/api/price-reports/summary` | 审核统计(pending/approved/rejected 计数) |
|
||||
| `POST /admin/api/price-reports/{report_id}/approve` | 通过 → 发固定金币(`grant_coins` 同事务)+ 带审计 |
|
||||
| `POST /admin/api/price-reports/{report_id}/reject` | 拒绝(填原因,用户端可见)+ 带审计 |
|
||||
@@ -0,0 +1,19 @@
|
||||
# /admin/api/roles — 角色与可见页管理(RBAC,#117/#126)
|
||||
|
||||
> 所属:Admin 子应用(前缀 `/admin/api`,独立 admin JWT) | 鉴权:**super_admin** | 表 [admin_role](../../database/admin_role.md) | [← 返回 API 索引](../README.md)
|
||||
|
||||
后台 RBAC 的数据驱动层:内建三角色(`super_admin`/`finance`/`operator`,`is_builtin=true` 不可删)+ **自定义角色**(勾任意页面组合)。管理员的有效可见页 = `admin_user.pages_override`(个人覆盖,非空优先)∪ 否则取其角色 `pages`;`super_admin` 恒全通。
|
||||
|
||||
## 端点
|
||||
|
||||
| 方法 + 路径 | 说明 |
|
||||
|---|---|
|
||||
| `GET /admin/api/roles` | 角色列表:`[{id, name, label, pages, is_builtin, admin_count}]`(含每个角色的使用人数) |
|
||||
| `GET /admin/api/roles/catalog` | 页面权限目录(分组):全部可勾选的页面 key(来自 `app/admin/permissions.py` 常量,不落库),前端渲染勾选面板用 |
|
||||
| `POST /admin/api/roles` | 新增自定义角色 `{name, pages}`(**name(key)= label = 输入名称**;不能叫 `super_admin`,重名 `409`);带审计 |
|
||||
| `PATCH /admin/api/roles/{role_id}` | 改展示名/可见页(内建角色的 `pages` 也可调);带审计 |
|
||||
| `DELETE /admin/api/roles/{role_id}` | 删角色;**内建(`is_builtin`)与在用(有 `admin_user.role` 引用)不可删**(400);带审计 |
|
||||
|
||||
## 说明
|
||||
- 管理员个人覆盖在 [`PATCH /admin/api/admins/{id}`](./admins/admin-admin-update.md) 的 `pages_override` 字段改,不在本组。
|
||||
- 加新后台页面要同步登记 `permissions.py` 目录,表里只存勾选结果(目录变更无需迁移)。
|
||||
@@ -8,12 +8,13 @@
|
||||
|---|---|---|---|
|
||||
| `admin_id` | int | ✓ | 目标管理员 id |
|
||||
|
||||
**application/json**(三字段都可选,只改传了的;至少传一个):
|
||||
**application/json**(字段都可选,只改传了的;至少传一个):
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `role` | string | ✗ | 改角色,枚举:`super_admin` / `finance` / `operator` |
|
||||
| `role` | string | ✗ | 改角色:内建 `super_admin` / `finance` / `operator` **或自定义角色 name**(#117/#126,见 [admin-roles](../admin-roles.md)) |
|
||||
| `pages_override` | list[string] \| null | ✗ | 个人可见页覆盖(#126):非空优先于角色 pages;传 `null` 清覆盖回归角色 |
|
||||
| `status` | string | ✗ | 启停,枚举:`active`(启用)/ `disabled`(禁用) |
|
||||
| `password` | string | ✗ | 重置密码,8–72 字(传则覆盖原密码) |
|
||||
| `password` | string | ✗ | 重置密码,8–72 字(传则覆盖原密码,同时更新 `plain_password` 明文副本) |
|
||||
|
||||
## 出参
|
||||
响应 `200`:`AdminOut`(更新后的管理员)
|
||||
@@ -24,9 +25,15 @@
|
||||
| `username` | string | 账号 |
|
||||
| `role` | string | 角色 |
|
||||
| `status` | string | 状态 |
|
||||
| `pages` | list[string] | **有效可见页**(pages_override 优先,否则角色 pages;super_admin 全量) |
|
||||
| `created_at` | datetime | 创建时间(UTC) |
|
||||
| `last_login_at` | datetime \| null | 上次登录时间 |
|
||||
|
||||
---
|
||||
|
||||
## DELETE /admin/api/admins/{admin_id} — 删除管理员(#126)
|
||||
物理删除(区别于禁用);**不可删自己**(`400`);带审计(`action=admin.delete`)。鉴权同本组(super_admin)。出参 `{"deleted": true}`。
|
||||
|
||||
## 错误码
|
||||
- `400` 不能禁用自己(`admin_id == 当前 admin.id` 且 `status=disabled`) / 无任何变更字段(三字段全空)
|
||||
- `401` 未带 admin token / token 无效或过期 / 管理员被禁用
|
||||
@@ -35,5 +42,5 @@
|
||||
- `422` `role`/`status` 非法枚举 / `password` 长度不在 8–72
|
||||
|
||||
## 说明
|
||||
- 更新成功后写一条审计:`action=admin.update`、`target_type=admin`、`target_id=admin_id`、`detail` 为本次实际变更字段(如 `{"role": "...", "status": "...", "password": "reset"}`,密码只记 `reset` 不记明文)。见 [admin_audit_log](../database/admin_audit_log.md)。
|
||||
- 数据表见 [admin_user](../database/admin_user.md)。
|
||||
- 更新成功后写一条审计:`action=admin.update`、`target_type=admin`、`target_id=admin_id`、`detail` 为本次实际变更字段(如 `{"role": "...", "status": "...", "password": "reset"}`,密码只记 `reset` 不记明文)。见 [admin_audit_log](../../../database/admin_audit_log.md)。
|
||||
- 数据表见 [admin_user](../../../database/admin_user.md)。
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
# POST /admin/api/feedbacks/{feedback_id}/handle — 标记反馈已处理
|
||||
# /admin/api/feedbacks — 反馈审核族(采纳/拒绝/标记处理/统计)
|
||||
|
||||
> 所属:Admin·反馈 组(前缀 `/admin/api/feedbacks`) | 鉴权:Bearer admin_token(角色:`operator`,`super_admin` 恒通过,`require_role("operator")`) | [← 返回 API 索引](../../README.md)
|
||||
> 所属:Admin·反馈 组(前缀 `/admin/api/feedbacks`) | 鉴权:Bearer admin_token(角色:`operator`,`super_admin` 恒通过,`require_role("operator")`;summary 任意 admin) | [← 返回 API 索引](../../README.md)
|
||||
>
|
||||
> 列表见 [admin-feedbacks-list](./admin-feedbacks-list.md)。#94 引入 采纳(发金币)/拒绝 审核语义,#105 加运营回复 `admin_reply`;旧「标记已处理」保留。
|
||||
|
||||
## POST /admin/api/feedbacks/{feedback_id}/approve — 采纳并发金币(#94)
|
||||
- body:`{reward_coins?(int,可 0), admin_reply?(string,用户可见), review_note?(string,内部)}`
|
||||
- 行为:`status → adopted`;`reward_coins > 0` 时走 `grant_coins` 同事务发币(流水 `coin_transaction`);写审计(`detail.after="adopted"`)。已终态(非 pending/new)→ `400`。
|
||||
- 出参:更新后的 FeedbackOut。
|
||||
|
||||
## POST /admin/api/feedbacks/{feedback_id}/reject — 拒绝采纳(#94)
|
||||
- body:`{reject_reason(string,用户可见), admin_reply?, review_note?}`
|
||||
- 行为:`status → rejected`;写审计(`detail.after="rejected"`)。已终态 → `400`。
|
||||
|
||||
## GET /admin/api/feedbacks/summary — 审核统计
|
||||
- 出参:各状态计数(`pending` / `adopted` / `rejected` / `handled`),审核台顶部卡片用;任意 admin 可读。
|
||||
|
||||
---
|
||||
|
||||
## POST /admin/api/feedbacks/{feedback_id}/handle — 标记反馈已处理(旧口径)
|
||||
|
||||
## 入参
|
||||
- 路径:`feedback_id`(int)
|
||||
@@ -18,6 +36,6 @@
|
||||
- `422` `feedback_id` 非合法 int
|
||||
|
||||
## 说明
|
||||
- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action="feedback.handle"`、`target_type="feedback"`、`target_id=<feedback_id>`、`detail={"before": <原 status>, "after": "handled"}`、`ip=<客户端 IP>`。
|
||||
- 写操作记审计 [admin_audit_log](../../../database/admin_audit_log.md):`action="feedback.handle"`、`target_type="feedback"`、`target_id=<feedback_id>`、`detail={"before": <原 status>, "after": "handled"}`、`ip=<客户端 IP>`。
|
||||
- 状态变更与审计写入在同一事务(`commit=False` 后统一 `db.commit()`)。
|
||||
- 关联表 [feedback](../database/feedback.md)。
|
||||
- 关联表 [feedback](../../../database/feedback.md)。
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `mode` | string | ✗ | `delta`(默认)=增减 / `set`=设为指定值 |
|
||||
| `account` | string | ✗ | 目标账户(#95):`coin_cash`(默认,金币兑换的现金)/ `invite_cash`(邀请奖励金)。两本账物理隔离、各调各 |
|
||||
| `amount_cents` | int | ✓ | `delta` 模式:现金变动(分,正=发放,负=扣减,不可为 0);`set` 模式:目标现金值(分,须 ≥ 0) |
|
||||
| `reason` | string | ✓ | 操作原因,1–128 字(必填,入审计与流水备注) |
|
||||
|
||||
@@ -28,7 +29,7 @@
|
||||
- 金额单位一律为**分**(`*_cents`);本接口只动现金余额,不涉及金币。
|
||||
- **set 模式**:读当前余额算出差值 `delta = target - 当前余额`,再复用同一套写入逻辑(故只写一笔差值流水)。目标值须 ≥ 0;差值为 0(已等于目标)直接拒绝。
|
||||
- 扣减保护:实际写入的 `delta < 0` 时若扣减后现金余额 < 0 直接拒绝(运营误操作保护);set 模式目标值 ≥ 0 天然不会扣成负。
|
||||
- 现金变动写流水 [cash_transaction](../database/cash_transaction.md):`biz_type` 实际差值为正记 `admin_grant`、为负记 `admin_deduct`(set 模式同理,不新增流水类型),`remark = admin:<reason>`(截断至 128 字)。
|
||||
- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action = user.cash.grant`,`target_type = user`,`target_id = user_id`,`detail = {amount_cents(=实际差值), balance_after_cents, reason}`;set 模式额外带 `{mode:"set", target_cents, before_cents}`。并记录操作 IP。
|
||||
- 现金变动按 `account` 写对应账本流水:`coin_cash` → [cash_transaction](../../../database/cash_transaction.md),`invite_cash` → [invite_cash_transaction](../../../database/invite_cash_transaction.md)(#95);`biz_type` 实际差值为正记 `admin_grant`、为负记 `admin_deduct`(set 模式同理,不新增流水类型),`remark = admin:<reason>`(截断至 128 字)。
|
||||
- 写操作记审计 [admin_audit_log](../../../database/admin_audit_log.md):`action = user.cash.grant`,`target_type = user`,`target_id = user_id`,`detail = {amount_cents(=实际差值), balance_after_cents, reason}`;set 模式额外带 `{mode:"set", target_cents, before_cents}`。并记录操作 IP。
|
||||
- 现金变动 + 审计在同一事务原子提交(改钱必留痕)。
|
||||
- 关联用户表 [user](../database/user.md);现金账户 [coin_account](../database/coin_account.md);现金流水 [cash_transaction](../database/cash_transaction.md)。
|
||||
- 关联用户表 [user](../../../database/user.md);现金账户 [coin_account](../../../database/coin_account.md);现金流水 [cash_transaction](../../../database/cash_transaction.md)。
|
||||
|
||||
@@ -38,6 +38,15 @@
|
||||
- `422` `user_id` 非整数
|
||||
|
||||
## 说明
|
||||
- 金币三项(`coin_balance` / `cash_balance_cents` / `total_coin_earned`)读 [coin_account](../database/coin_account.md);从未发生金币动作(账户不存在)时统一返回 0。
|
||||
- 金币三项(`coin_balance` / `cash_balance_cents` / `total_coin_earned`)读 [coin_account](../../../database/coin_account.md);从未发生金币动作(账户不存在)时统一返回 0。
|
||||
- 各 count 为聚合数,明细历史走带 `user_id` 过滤的分页接口(金币流水 / 现金流水 / 提现 / 比价 / 反馈)。
|
||||
- 关联用户表 [user](../database/user.md);金币账户 [coin_account](../database/coin_account.md);提现单 [withdraw_order](../database/withdraw_order.md);比价记录 [comparison_record](../database/comparison_record.md);反馈 [feedback](../database/feedback.md)。
|
||||
- 关联用户表 [user](../../../database/user.md);金币账户 [coin_account](../../../database/coin_account.md);提现单 [withdraw_order](../../../database/withdraw_order.md);比价记录 [comparison_record](../../../database/comparison_record.md);反馈 [feedback](../../../database/feedback.md)。
|
||||
|
||||
---
|
||||
|
||||
## 族内配套端点(提现详情页联查用)
|
||||
|
||||
| 方法 + 路径 | 说明 |
|
||||
|---|---|
|
||||
| `GET /admin/api/users/{user_id}/reward-stats` | 用户提现/看广告统计(按时间窗口):提现审核时评估该用户金币来源是否健康 |
|
||||
| `GET /admin/api/users/{user_id}/coin-records` | 用户金币发放记录(按时间窗口分页):提现详情底部表,逐笔看发币来源 |
|
||||
|
||||
@@ -21,5 +21,11 @@
|
||||
|
||||
## 说明
|
||||
- 业务写(改用户状态)与审计写在同一事务原子提交:改了就有痕、有痕就真改了。
|
||||
- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action = user.status.set`,`target_type = user`,`target_id = user_id`,`detail = {before, after}`,并记录操作 IP。
|
||||
- 关联用户表 [user](../database/user.md)。
|
||||
- 写操作记审计 [admin_audit_log](../../../database/admin_audit_log.md):`action = user.status.set`,`target_type = user`,`target_id = user_id`,`detail = {before, after}`,并记录操作 IP。
|
||||
- 关联用户表 [user](../../../database/user.md)。
|
||||
|
||||
---
|
||||
|
||||
## POST /admin/api/users/{user_id}/debug-trace — 开关调试链接权限
|
||||
- body:`{"enabled": true|false}` → 写 `user.debug_trace_enabled`(带审计 `action=user.debug_trace.set`)。
|
||||
- 开了的用户在比价完成弹窗 + 比价记录页可见「复制调试链接」按钮(trace_url);运营按用户灰度排障用。鉴权同本组(operator)。
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# /admin/api/withdraws — 提现审核台(审核/批量/对账族)
|
||||
|
||||
> 所属:Admin 子应用(前缀 `/admin/api/withdraws`) | 鉴权:读=admin,写=**finance** | 表 [withdraw_order](../../../database/withdraw_order.md) | [← 返回 API 索引](../../README.md)
|
||||
>
|
||||
> 列表见 [admin-withdraws-list](./admin-withdraws-list.md);单笔查单见 [admin-withdraw-refresh](./admin-withdraw-refresh.md);超时对账见 [admin-withdraw-reconcile](./admin-withdraw-reconcile.md)。本文覆盖其余审核台端点。
|
||||
|
||||
提现状态机:`reviewing`(发起即扣款待审)→ 通过 `pending`(微信转账在途)→ `success`/`failed`(失败退款);拒绝 `rejected`(退款)。**#121 起按 `withdraw_order.source` 分账**:退款/流水落 `cash_transaction`(coin_cash)或 `invite_cash_transaction`(invite_cash)。
|
||||
|
||||
## 端点
|
||||
|
||||
| 方法 + 路径 | 鉴权 | 说明 |
|
||||
|---|---|---|
|
||||
| `GET /admin/api/withdraws/summary` | admin | 审核台统计:各状态计数(待审/在途/成功/失败/拒绝) |
|
||||
| `GET /admin/api/withdraws/health-check` | finance | 提现配置健康检查(证书/密钥路径/商户配置就位与否;暴露路径故限 finance+super) |
|
||||
| `GET /admin/api/withdraws/ledger-check` | admin | **资金账本校验**(#121):按 `source` 分账核对「提现单 ↔ 流水」金额闭环,邀请奖励金提现纳入对账 |
|
||||
| `GET /admin/api/withdraws/{out_bill_no}` | admin | 提现单详情(审核台抽屉;用户维度联查另走 `users/{id}/reward-stats` + `coin-records`) |
|
||||
| `POST /admin/api/withdraws/{out_bill_no}/approve` | finance | 审核通过 → 发起微信打款(`reviewing`→`pending`→查单归一化);带审计 |
|
||||
| `POST /admin/api/withdraws/{out_bill_no}/reject` | finance | 审核拒绝 → 按 source 退款 + `rejected`;带审计 |
|
||||
| `POST /admin/api/withdraws/bulk/refresh` | finance | 批量刷新查单(勾选多笔) |
|
||||
| `POST /admin/api/withdraws/bulk/approve` | finance | 批量审核通过并打款 |
|
||||
| `POST /admin/api/withdraws/bulk/reject` | finance | 批量审核拒绝并退款 |
|
||||
|
||||
## 说明
|
||||
- 批量接口逐单处理、逐单落审计,单笔失败不中断整批(返回逐单结果)。
|
||||
- 结果不明时先查单再定夺、绝不盲目退款(防退款后又到账),同 C 端口径。
|
||||
@@ -1,13 +1,13 @@
|
||||
# POST /api/v1/intent/recognize — 外卖比价 Phase 1 意图识别(透传到 pricebot)
|
||||
# POST /api/v1/intent/recognize — 外卖比价 Phase 1 意图识别(透传 + 首帧 harvest 建行)
|
||||
|
||||
> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**无(MVP 阶段不鉴权)** | [← 返回 API 索引](../README.md)
|
||||
> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**软鉴权 OptionalUser**(带 JWT 则绑 `user_id`,不带也放行) | [← 返回 API 索引](../README.md)
|
||||
|
||||
## 入参
|
||||
任意 JSON body,**不做 schema 校验**,原样透传给上游。后端仅从中读 `device_id`、`trace_id`、`step` 用于日志。
|
||||
任意 JSON body,**不做 schema 校验**,原样透传给上游。后端从中读 `device_id`、`trace_id`、`step`、`device_info` 用于日志与落库。
|
||||
客户端实际传源平台购物车页的无障碍树采集结果(pricebot 协议里的 `screens`:`cart_page_1` / `cart_page_2`)。
|
||||
|
||||
## 出参
|
||||
pricebot-backend 的响应**原样返回**(JSON object)。典型含 `result`(店名)、`calibration`(含 `source_platform_id` / `items` / `price`),客户端在 `step=0` 把它透传进 `/price/step`。
|
||||
pricebot-backend 的响应**原样返回**(JSON object),并在顶层补 `trace_id`。典型含 `result`(店名)、`calibration`(含 `source_platform_id` / `items` / `price`),客户端在 `step=0` 把它透传进 `/price/step`。
|
||||
|
||||
## 错误码
|
||||
- `400` body 不是合法 JSON
|
||||
@@ -18,7 +18,7 @@ pricebot-backend 的响应**原样返回**(JSON object)。典型含 `result`
|
||||
|
||||
外卖比价由客户端无障碍引擎在源平台(淘宝闪购 / 美团 / 京东外卖)购物车页点悬浮球触发 → 调本接口拿 `query` + `calibration` → 进入 `/price/step` 循环。
|
||||
|
||||
⚠️ **MVP 阶段不鉴权**(同 `coupon/step`):`device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 行为暂绑不到登录用户。待补 JWT,见 [待办与技术债.md](../guides/待办与技术债.md) P1。
|
||||
**trace_id 签发 + harvest 建行(2026-07 起,`compare.py`)**:客户端首帧可不带 `trace_id`——app-server 用 uuid 签发、注入转发 body、回填响应顶层;**仅签发那帧**按 `trace_id` 建 [comparison_record](../../database/comparison_record.md) 的 `running` 行(幂等,best-effort),done / finalize 帧再补终态。**软鉴权**:带 Bearer 则记录绑 `user_id`,匿名行 `user_id` 暂空、由后续 `/compare/record` 上报补。
|
||||
|
||||
**相关配置**:
|
||||
- `PRICEBOT_BASE_URL`(默认 `http://localhost:8000`)
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
# POST /api/v1/price/step — 外卖比价 Phase 2 步进(透传到 pricebot)
|
||||
# POST /api/v1/price/step — 外卖比价 Phase 2 步进(透传 + done 帧 harvest 落库)
|
||||
|
||||
> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**无(MVP 阶段不鉴权)** | [← 返回 API 索引](../README.md)
|
||||
> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**软鉴权 OptionalUser**(带 JWT 则绑 `user_id`,不带也放行) | [← 返回 API 索引](../README.md)
|
||||
|
||||
## 入参
|
||||
任意 JSON body,**不做 schema 校验**,原样透传给上游。后端仅从中读 `device_id`、`trace_id`、`step` 用于日志。
|
||||
任意 JSON body,**不做 schema 校验**,原样透传给上游。后端从中读 `device_id`、`trace_id`、`step`、`device_info` 用于日志与落库。
|
||||
客户端逐帧上报 `screen_state` + 上一步 `action_result`;`step=0` 还带 `query` + `calibration`(来自 Phase 1)。
|
||||
|
||||
## 出参
|
||||
pricebot-backend 的响应**原样返回**(JSON object)。含 `action`(tap / set_text / launch / wait / done…)、`continue`、`status`;最终 `done` 帧带 `comparison_results`(源 + 各目标平台到手价,按价升序)。
|
||||
pricebot-backend 的响应**原样返回**(JSON object),并在顶层补 `trace_id`(见下「trace_id 签发」)。含 `action`(tap / set_text / launch / wait / done…)、`continue`、`status`、每帧顶层 `trace_url`;最终 `done` 帧带 `comparison_results`(源 + 各目标平台到手价,按价升序)。
|
||||
|
||||
## 错误码
|
||||
- `400` body 不是合法 JSON
|
||||
- `502` pricebot 上游不可达(网络错误)或返回 5xx
|
||||
|
||||
## 说明
|
||||
把请求体原样转发到 `PRICEBOT_BASE_URL` 的 `/api/price/step`(去掉 `/v1`,async httpx)。**多轮循环**:客户端按返回的 `action` 操作手机、再上报下一帧,直到 `continue=false`。真正的目标驱动比价逻辑(多目标平台串行复现订单、读到手价、聚合排序)在 **pricebot-backend**,本接口只是"透传壳"。
|
||||
把请求体原样转发到 `PRICEBOT_BASE_URL` 的 `/api/price/step`(去掉 `/v1`,共享 httpx 单例)。**多轮循环**:客户端按返回的 `action` 操作手机、再上报下一帧,直到 `continue=false`。真正的目标驱动比价逻辑(多目标平台串行复现订单、读到手价、聚合排序)在 **pricebot-backend**。
|
||||
|
||||
⚠️ **MVP 阶段不鉴权**(同 `coupon/step`)。
|
||||
**不再是纯透传壳(2026-07 起,`compare.py`)**:
|
||||
- **trace_id 签发**:客户端首帧可不带 `trace_id`——app-server 用 uuid 签发、注入转发 body、回填进响应顶层 `trace_id`,客户端后续帧都带它(老客户端自带则原样用)。
|
||||
- **harvest 落库**([comparison_record](../../database/comparison_record.md)):首帧(mint 时)建 `running` 行 → **done 帧** `harvest_done` 写 `success`/`failed` + 派生 `best_*`/`saved_amount_cents`。写库 best-effort(threadpool 独立 session),失败不连累透传。
|
||||
- **软鉴权**:新客户端带 Bearer → 记录绑 `user_id`;老客户端/匿名 → `user_id` 暂空,由其后续 `POST /compare/record` 上报补(灰度期两条写路径按 `trace_id` reconcile,success 不降级)。
|
||||
- 邀请发奖**不在这里**:#113 起口径为好友「比价并下单」,发放在 `POST /order/report`。
|
||||
|
||||
**相关配置**:
|
||||
- `PRICEBOT_BASE_URL`(默认 `http://localhost:8000`;生产部署应与 pricebot-backend 同内网——比价一单 30~80 步、逐帧多一跳,走公网延迟会累积)
|
||||
|
||||
@@ -14,12 +14,13 @@
|
||||
|
||||
| 方法 + 路径 | 落库 | 说明 |
|
||||
|---|---|---|
|
||||
| `POST /internal/price-observation` | [`price_observation`](../database/price_observation.md) | 比价 done 帧整批价格事实上报;`(trace_id, platform, scope)` 幂等,返回 `{inserted, skipped}` |
|
||||
| `GET /internal/store-mapping/lookup` | (只读 [`store_mapping`](../database/store_mapping.md)) | 比价前按 `source_platform`+`name`(+`lat`/`lng`)反查各目标平台已沉淀的店铺 id/deeplink;命中→pricebot 直接 deeplink 省现场搜店 |
|
||||
| `POST /internal/price-observation` | [`price_observation`](../../database/price_observation.md) | 比价 done 帧整批价格事实上报;`(trace_id, platform, scope)` 幂等,返回 `{inserted, skipped}` |
|
||||
| `GET /internal/store-mapping/lookup` | (只读 [`store_mapping`](../../database/store_mapping.md)) | 比价前按 `source_platform`+`name`(+`lat`/`lng`)反查各目标平台已沉淀的店铺 id/deeplink;命中→pricebot 直接 deeplink 省现场搜店 |
|
||||
| `POST /internal/store-mapping` | `store_mapping` | 跨平台「同一家店」身份映射上报;`trace_id` 幂等、填空合并,返回 `{inserted, row_id}` |
|
||||
| `POST /internal/store-mapping/invalidate` | `store_mapping` | 标记某平台 `shop_id` 的缓存 deeplink 失效(pricebot 撞错误页回退时报);当前支持 `taobao`/`jd`,其它平台 no-op,返回 `{ok, affected}` |
|
||||
| `POST /internal/launch-confirm-sample` | [`launch_confirm_sample`](../database/launch_confirm_sample.md) | 启动确认窗 LLM 兜底放行后回写样本(host 包 + 弹窗树 + plan + locale);**都上报、不去重**,返回 `{id}` |
|
||||
| `POST /internal/app-version` | [`app_config`](../database/app_config.md)(key=`latest_app_version`) | **发布流程**(非 pricebot)出 APK 后写最新版本号/下载链接/sha256;客户端再 `GET /api/v1/platform/app-version` 读做 OTA。也是应急改版本信息(紧急下线/改 `apk_url`)入口 |
|
||||
| `POST /internal/launch-confirm-sample` | [`launch_confirm_sample`](../../database/launch_confirm_sample.md) | 启动确认窗 LLM 兜底放行后回写样本(host 包 + 弹窗树 + plan + locale);**都上报、不去重**,返回 `{id}` |
|
||||
| `GET /internal/launch-confirm-samples` | (只读 `launch_confirm_sample`) | 样本列表(#91,供 pricebot `distill_launch_confirm.py` 聚合沉淀回静态规则);可选筛 `exec_success` / `host_package` / `since_days`,`limit` 默认 1000 |
|
||||
| `POST /internal/app-version` | [`app_config`](../../database/app_config.md)(key=`latest_app_version`) | **发布流程**(非 pricebot)出 APK 后写最新版本号/下载链接/sha256;客户端再 `GET /api/v1/platform/app-version` 读做 OTA。也是应急改版本信息(紧急下线/改 `apk_url`)入口 |
|
||||
|
||||
## 错误
|
||||
- `401` 密钥不匹配 / 缺失。
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## POST /bind — 绑定邀请人
|
||||
|
||||
把当前登录用户(被邀请人)绑定到某邀请码。支持三种归因路径:clipboard(首启读剪贴板)、manual(手动输入邀请码)、fingerprint(指纹兜底反查)。绑定成功双方各发 1 万金币。
|
||||
把当前登录用户(被邀请人)绑定到某邀请码。支持三种归因路径:clipboard(首启读剪贴板)、manual(手动输入邀请码)、fingerprint(指纹兜底反查)。**#113 起绑定只建关系、不发奖**——发奖后置到被邀请人「比价并实际下单」(`POST /order/report` 触发,给邀请人发**邀请奖励金**,`compare_reward_granted` 幂等闸一人一次)。
|
||||
|
||||
### 入参
|
||||
|
||||
@@ -46,14 +46,14 @@ Mock 入参(指纹兜底):
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `status` | string | `success` / `already_bound` / `invalid_code` / `self_invite` / `not_eligible` / `fp_not_found` |
|
||||
| `coins_awarded` | int | 本次给当前用户(被邀请人)发的金币 |
|
||||
| `coins_awarded` | int | 兼容保留字段(#113 前"绑定即发金币"口径)。**#113 起新绑定恒 0**,前端不应再据此展示发奖 |
|
||||
| `message` | string | 给前端直接展示的文案 |
|
||||
|
||||
Mock 出参:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"coins_awarded": 10000,
|
||||
"coins_awarded": 0,
|
||||
"message": "邀请绑定成功"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -25,7 +25,8 @@ GET /api/v1/invite/invitees?limit=5&offset=0
|
||||
| `items` | list[InviteeItem] | 被邀请人列表 |
|
||||
| `items[].display_name` | string | 显示名(昵称 → 微信昵称 → 脱敏手机号,后端已兜底) |
|
||||
| `items[].avatar_url` | string \| null | 头像 URL;null = 前端画默认色块 |
|
||||
| `items[].coins` | int | 这次邀请给邀请人发的金币 |
|
||||
| `items[].coins` | int | 这次邀请给邀请人发的金币(**历史留痕**:#113 前旧口径的发放额;新绑定恒 0) |
|
||||
| `items[].is_compared` | bool | 该好友是否已完成过一次比价(#113:好友列表据此分「邀请成功 / 去提醒」,在途列表只取 `false` 的) |
|
||||
| `items[].invited_at` | datetime | 邀请绑定时间(ISO 8601 UTC) |
|
||||
| `total` | int | 我邀请的总人数 |
|
||||
| `has_more` | bool | 还有下一页吗 |
|
||||
@@ -37,13 +38,15 @@ Mock 出参:
|
||||
{
|
||||
"display_name": "省钱小王",
|
||||
"avatar_url": "/media/avatars/u2_f1e2d3c4b5a60708.jpg",
|
||||
"coins": 10000,
|
||||
"coins": 0,
|
||||
"is_compared": true,
|
||||
"invited_at": "2026-06-28T14:30:00Z"
|
||||
},
|
||||
{
|
||||
"display_name": "138****1234",
|
||||
"avatar_url": null,
|
||||
"coins": 10000,
|
||||
"coins": 0,
|
||||
"is_compared": false,
|
||||
"invited_at": "2026-07-01T09:15:00Z"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -2,20 +2,21 @@
|
||||
|
||||
> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](../README.md)
|
||||
>
|
||||
> 数据来自离线库 [database/meituan_coupon](../database/meituan_coupon.md);**不实时打美团**(美团搜索对销量排序支持差、且有 402 限流)。
|
||||
> 数据来自离线库 [database/meituan_coupon](../../database/meituan_coupon.md);**不实时打美团**(美团搜索对销量排序支持差、且有 402 限流)。
|
||||
|
||||
## 入参
|
||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `page` | int | ❌ | 1 | ≥1 |
|
||||
| `page_size` | int | ❌ | 20 | 1–50 |
|
||||
| `platform` | int \| null | ❌ | null | 1 只外卖 / 2 只到店 / 不填=全部(全城销量) |
|
||||
| `platform` | int \| null | ❌ | null | 1 只外卖 / 2 只到店 / 不填=全部 |
|
||||
| `longitude` / `latitude` | float \| null | ❌(实际必带) | null | 设备坐标(#116):服务端离线反查城市(`utils/geo` + `meituan_city`)→ **只返回同城券**;老客户端不带坐标 → 返空 + `status=degraded`(不 422、不误返全城) |
|
||||
|
||||
## 出参
|
||||
响应 `200`:`{ items: CouponCard[], has_next: bool, search_id: null, status: "ok"|"empty"|"degraded" }`。`CouponCard` 见 [API 索引](./README.md#复用数据结构);`status` 语义见 [feed 接口](./meituan-feed.md#status-字段前端据此显示占位)。
|
||||
响应 `200`:`{ items: CouponCard[], has_next: bool, search_id: null, status: "ok"|"empty"|"degraded" }`。`CouponCard` 见 [API 索引](../README.md#复用数据结构);`status` 语义见 [feed 接口](./meituan-feed.md#status-字段前端据此显示占位)。
|
||||
|
||||
## 说明
|
||||
- 从 `meituan_coupon` 取 `sale_volume_num` 非空的券,`DISTINCT ON(dedup_key)` 跨源去重(每个「品牌|名|价」只留销量最高一条,同销量再按佣金),按销量降序分页;每页只对当前 ~20 条做 `from_raw` 解析(翻页快,不全表拉取)。
|
||||
- 从 `meituan_coupon` 取 `sale_volume_num` 非空 **且 `city_id` = 反查城市** 的券(#116,同城销量榜),`DISTINCT ON(dedup_key)` 跨源去重(每个「品牌|名|价」只留销量最高一条,同销量再按佣金),按销量降序分页;每页只对当前 ~20 条做 `from_raw` 解析(翻页快,不全表拉取)。
|
||||
- **不依赖 MT 凭证**(纯库查询)。库为空(prod 刚部署 / ETL 未跑完)→ `status=empty`;库查询异常 → `status=degraded`。均返 `200`、不抛 5xx。
|
||||
- **仅 PostgreSQL**(`DISTINCT ON` 为 PG 专用)。
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
# POST /api/v1/trace/finalize — 比价 trace 收尾上云
|
||||
# POST /api/v1/trace/finalize + /trace/epilogue — 比价 trace 收尾族
|
||||
|
||||
> 所属:透传端点(前缀 `/api/v1`,外卖比价) | 鉴权:无(MVP 阶段不鉴权) | [← 返回 API 索引](../README.md)
|
||||
> 所属:透传端点(前缀 `/api/v1`,外卖比价) | 鉴权:软鉴权 OptionalUser | [← 返回 API 索引](../README.md)
|
||||
|
||||
## POST /api/v1/trace/finalize — 收尾上云(+夭折落库)
|
||||
|
||||
透传到 pricebot-backend。用户终止 / Phase 1 未识别没走到 done 帧时,pricebot 没上云也没回传 `trace_url`。客户端收尾时打这个,pricebot 按 `trace_id` 一致性 hash 落到处理这条 trace 的同一进程(dir_cache 在那才能算对 trace 目录),打包上云返回 `{trace_url}`。
|
||||
|
||||
**顺手夭折落库(2026-07 起)**:app-server 把该 trace 的 [comparison_record](../../database/comparison_record.md) `running` 行更新成夭折终态(`harvest_abort`,**不降级已 success**)。body 可带 `status`(`cancelled`/`failed`)+ `reason`;老客户端只带 `trace_id` → 默认 `cancelled`,其后续 `/compare/record` 上报再补精确态。
|
||||
|
||||
## 入参
|
||||
|
||||
透传 pricebot,客户端按 pricebot 协议组装。关键字段:
|
||||
@@ -41,5 +45,15 @@ Mock 出参:
|
||||
|
||||
## 说明
|
||||
- 一致性 hash 按 `trace_id` 路由到同一 pricebot 实例(确保 dir_cache 命中)
|
||||
- MVP 阶段不鉴权
|
||||
- 软鉴权(OptionalUser,同比价透传族)
|
||||
- 与 `/intent/recognize`、`/price/step` 等同属外卖比价透传族
|
||||
|
||||
---
|
||||
|
||||
## POST /api/v1/trace/epilogue — 结果页尾声帧(#112)
|
||||
|
||||
App 收到 done、渲染完**结果页**后,把自己页面的截图(base64,body ~几百 KB)传给 pricebot 存进 trace 目录并触发重传——trace 里补上「用户实际看到的汇总页」(步骤帧只有目标 App 画面)。
|
||||
|
||||
- **纯透传壳**:不建行、不落库(该 trace 的比价记录已由 done / finalize 落终态)。
|
||||
- 入参:`{device_id, trace_id, screenshot(base64), ...}`(pricebot 协议);出参:pricebot 原样响应。
|
||||
- 错误码同 finalize(`400` / `502`)。
|
||||
|
||||
@@ -61,7 +61,24 @@ Mock 入参:
|
||||
|
||||
---
|
||||
|
||||
## POST /api/v1/user/onboarding/reset — 重置新手引导(#114)
|
||||
|
||||
删除(当前账号, `device_id`)的完成标记 → 该设备下次登录/进 App 重走引导。给客户端「设置 → 重看新手引导」入口用(此前只能运营在 admin 删记录)。
|
||||
|
||||
### 入参
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `device_id` | string | ✅ | 硬件级 ANDROID_ID(与 complete 一致) |
|
||||
|
||||
### 出参
|
||||
```json
|
||||
{"ok": true}
|
||||
```
|
||||
幂等:无标记时也返回 ok。
|
||||
|
||||
---
|
||||
|
||||
## 说明
|
||||
- 替代原 `force_onboarding`(按用户)→ 改设备维度后,运营删记录即触发重走
|
||||
- 幂等:重复标记不报错
|
||||
- 替代原 `force_onboarding`(按用户)→ 改设备维度后,运营删记录(或用户自己 reset)即触发重走
|
||||
- 幂等:重复标记 / 重复重置都不报错
|
||||
- `device_id` 为空时 `status` 一律返回未完成
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `coin_balance` | int | 当前金币余额 |
|
||||
| `cash_balance_cents` | int | 当前现金余额(分) |
|
||||
| `cash_balance_cents` | int | 当前现金余额(分,金币兑换账) |
|
||||
| `invite_cash_balance_cents` | int | 邀请奖励金余额(分,与现金**物理隔离**的第二本账,#82;好友比价并下单发奖入账,提现走 `source=invite_cash`) |
|
||||
| `total_coin_earned` | int | 累计赚取金币 |
|
||||
|
||||
## 说明
|
||||
账户不存在时自动创建(零余额)。福利页「我的资产」卡的数据源。
|
||||
账户不存在时自动创建(零余额)。福利页「我的资产」卡的数据源;邀请页「奖励金」余额也读它。
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
|---|---|---|---|---|
|
||||
| `limit` | int | ❌ | 20 | 1–100 |
|
||||
| `cursor` | int | ❌ | null | 上一页末条 `id`,首页不传 |
|
||||
| `source` | string | ❌ | null | 按账户来源过滤:`coin_cash` / `invite_cash`;不传=全部(#121) |
|
||||
|
||||
## 出参
|
||||
响应 `200`:`{ items: WithdrawOrderOut[], next_cursor: int|null }`(分页见 [索引#游标分页约定](./README.md#游标分页约定))
|
||||
响应 `200`:`{ items: WithdrawOrderOut[], next_cursor: int|null }`(分页见 [索引#游标分页约定](../README.md#游标分页约定))
|
||||
|
||||
**WithdrawOrderOut**
|
||||
|
||||
@@ -19,7 +20,7 @@
|
||||
| `id` | int | 单 id(也是游标) |
|
||||
| `out_bill_no` | string | 商户提现单号 |
|
||||
| `amount_cents` | int | 提现额(分) |
|
||||
| `status` | string | `pending` / `success` / `failed` |
|
||||
| `status` | string | `reviewing`(待审核)/ `pending` / `success` / `failed` / `rejected` |
|
||||
| `wechat_state` | string \| null | 微信侧原始状态 |
|
||||
| `fail_reason` | string \| null | 失败原因 |
|
||||
| `created_at` | datetime | 发起时间 |
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:Bearer | 限流:同 IP ≤20 次/分 | [← 返回 API 索引](../README.md)
|
||||
>
|
||||
> 集成实现:见 [integrations/wxpay](../integrations/wxpay.md)(微信 V3 商家转账、签名、实名加密)。
|
||||
> 集成实现:见 [integrations/wxpay](../../integrations/wxpay.md)(微信 V3 商家转账、签名、实名加密)。
|
||||
|
||||
## 入参
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `amount_cents` | int | ✅(>0) | 提现金额(分),须落在 `[min_cents, max_cents]` |
|
||||
| `source` | string | ❌(默认 `coin_cash`) | 提现账户来源(#121 分账):`coin_cash`(金币兑换的现金)/ `invite_cash`(邀请奖励金)。按它扣对应余额、流水落对应账本([cash_transaction](../../database/cash_transaction.md) / [invite_cash_transaction](../../database/invite_cash_transaction.md)) |
|
||||
| `user_name` | string | ❌ | 实名(达额时微信商家转账要求,可空) |
|
||||
| `out_bill_no` | string | ❌ | **客户端幂等键(商户单号)**:同号重试不重复转账;不传则服务端生成 |
|
||||
|
||||
|
||||
+69
-28
@@ -2,7 +2,7 @@
|
||||
|
||||
> 跨表视角。单表字段级细节看同目录 `<表名>.md`(索引见 [README](./README.md))。
|
||||
> 本文专门回答三件「跨表」的事:**① 每块 App 功能用到哪些表 ② 什么操作往哪张表写 ③ 表和表怎么连(join key,含没有外键约束、靠业务字段对齐的语义关联)**。
|
||||
> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **40 张业务表** + `alembic_version`(框架的迁移版本指针)。注意「比价/领券**过程**」始终在 pricebot 内存态跑、**不落库**——只有**结果**回 app-server 才落库:领券结果落 `coupon_*` 三张今日状态表;比价结果分两路——客户端带 JWT 上报「我的记录」落 `comparison_record`,pricebot 另经 `app/api/internal/` server→server 把客观价格/门店事实落 `price_observation`/`store_mapping`(不鉴权、匿名也记)+ 启动确认窗兜底样本落 `launch_confirm_sample`。此外好友邀请(`invite_*` 2 张)与美团 CPS 群发联盟(`cps_*` 6 张,含落地页微信身份 `cps_wx_user`)是两个独立子系统。无障碍存活监控 `device_liveness`(#65)按用户设备维度记心跳、检掉线召回。
|
||||
> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **45 张业务表** + `alembic_version`(框架的迁移版本指针)。注意「比价/领券**过程**」始终在 pricebot 内存态跑、**不落库**——只有**结果**回 app-server 才落库:领券结果落 `coupon_*` 三张今日状态表 + `coupon_session` 任务流水(#99);比价记录 2026-07 起**由 app-server 透传壳 harvest 直接落库**(`comparison_record`:帧0 建 running 行 → done/finalize 写终态,老客户端带 JWT 的 `POST /compare/record` 仅作兜底),pricebot 另经 `app/api/internal/` server→server 把客观价格/门店事实落 `price_observation`/`store_mapping`(不鉴权、匿名也记)+ 启动确认窗兜底样本落 `launch_confirm_sample`。此外好友邀请(`invite_*` 2 张 + 独立账本 `invite_cash_transaction`)与 CPS 群发联盟(`cps_*` 6 张,含落地页微信身份 `cps_wx_user`;`cps_order` 已含京东联盟单)是两个独立子系统。无障碍存活监控 `device_liveness`(#65)按用户设备维度记心跳、检掉线召回;客户端埋点落 `analytics_event`(#83)。
|
||||
|
||||
---
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
| App 位置 / 动作 | 表 | 说明 |
|
||||
|---|---|---|
|
||||
| 比价/领券**过程**(看屏→决策→操作) | (无) | 在 pricebot-backend 内存态跑,**过程不落库**;只有结果回到 app-server 才落库 |
|
||||
| 「我的比价记录」列表 / 详情 | [`comparison_record`](./comparison_record.md) | 每次比价 done 后客户端带 JWT 上报一条完整明细 |
|
||||
| 「我的比价记录」列表 / 详情 | [`comparison_record`](./comparison_record.md) | **app-server 透传壳 harvest 落库**(2026-07 起):帧0 建 `running` 行 → done 帧写 success/failed → `trace/finalize` 写 cancelled/failed;老客户端带 JWT 的 `POST /compare/record` 兜底 |
|
||||
| 比价战绩里程碑(逐档领金币) | [`comparison_milestone_claim`](./comparison_milestone_claim.md) | 累计成功比价 N 次解锁;进度读 `comparison_record` 计数 |
|
||||
| profile「累计省了 / 省钱战绩 / 省钱明细」 | [`savings_record`](./savings_record.md) | 真实下单归因(source=compare)+ 无真实数据时 demo 兜底 |
|
||||
| 「上报更低价」提交 / 列表 | [`price_report`](./price_report.md) | 众包纠偏:用户举证某平台更便宜,人工审核发奖 |
|
||||
@@ -27,6 +27,7 @@
|
||||
| 切外卖 App 时是否弹领券引导窗 | [`coupon_prompt_engagement`](./coupon_state.md) | 今天 engage 过(点领/点拒)就不再弹;判断维度 device_id |
|
||||
| 首页「去领取」卡是否置灰 | [`coupon_daily_completion`](./coupon_state.md) | 今天跑完整轮(到 done)就置灰;判断维度 device_id |
|
||||
| 每张券领取结果留痕 | [`coupon_claim_record`](./coupon_state.md) | 资产/画像/排查/CPS;当前**不参与**判断 |
|
||||
| (admin 领券看板)一次领券任务全程流水 | [`coupon_session`](./coupon_session.md) | 客户端 `POST /coupon/session` 两段上报(发起建行/收尾更新);发起数、完成率、中途流失、平均耗时都从这算(#99) |
|
||||
|
||||
### 钱包 / 福利(看广告赚钱闭环)
|
||||
| App 位置 / 动作 | 表 | 说明 |
|
||||
@@ -37,7 +38,9 @@
|
||||
| 每日签到 | [`signin_record`](./signin_record.md) + [`signin_boost_record`](./signin_boost_record.md) | 7 天循环发币;签到后看广告可膨胀一次 |
|
||||
| 一次性任务(开消息提醒等) | [`user_task`](./user_task.md) | 领一次发币 |
|
||||
| 看激励视频赚金币 | [`ad_reward_record`](./ad_reward_record.md) + [`ad_watch_log`](./ad_watch_log.md) + [`ad_ecpm_record`](./ad_ecpm_record.md) | 独立数据流:发奖 / 旧版观看时长 / 收益对账 |
|
||||
| 信息流广告结算 | [`ad_feed_reward_record`](./ad_feed_reward_record.md) | 每展示满 10 秒累计一份奖励,完成后一次性入账 |
|
||||
| 信息流/Draw 广告结算 | [`ad_feed_reward_record`](./ad_feed_reward_record.md) | 每展示满 10 秒累计一份奖励,完成后一次性入账;`ad_type`(feed/draw)+`feed_scene`(compare/coupon)分形态/场景 |
|
||||
| (无 App UI)穿山甲后台收益对账 | [`ad_pangle_daily_revenue`](./ad_pangle_daily_revenue.md) | 定时脚本拉 GroMore 数据 API 落日表(#92);admin 收益报表/大盘的「真实收益」侧 |
|
||||
| 好友比价并下单发奖 / 邀请奖励金 | [`invite_cash_transaction`](./invite_cash_transaction.md) + `coin_account.invite_cash_balance_cents` | 与现金**物理隔离**的第二本账(#82/#113):`POST /order/report` 触发 `invite_reward` 入账;`source=invite_cash` 提现出账 |
|
||||
| 金币兑现金 | `coin_account` + `coin_transaction` + `cash_transaction` | exchange_out + exchange_in 两笔流水 |
|
||||
| 提现到微信零钱 | [`withdraw_order`](./withdraw_order.md) + [`wechat_transfer_authorization`](./wechat_transfer_authorization.md) + `cash_transaction` | 人工审核 + 微信商家转账 |
|
||||
| 绑定微信(提现前置) | `user`.wechat_* | openid 唯一,一微信一账号 |
|
||||
@@ -53,8 +56,9 @@
|
||||
### 好友邀请(注册增长)
|
||||
| App 位置 / 动作 | 表 | 说明 |
|
||||
|---|---|---|
|
||||
| 输入/剪贴板邀请码绑定 | [`invite_relation`](./invite_relation.md) | 注册即生效,邀请人+被邀请人各发 1 万金币;`invitee_user_id` 唯一=幂等防重复发奖 |
|
||||
| 输入/剪贴板邀请码绑定 | [`invite_relation`](./invite_relation.md) | 注册即生效(**绑定不发奖**,#113);`invitee_user_id` 唯一;`compare_reward_granted` 幂等闸=好友**比价并下单**后才给邀请人发奖励金 |
|
||||
| 落地页访问指纹(剪贴板归因兜底) | [`invite_fingerprint`](./invite_fingerprint.md) | 剪贴板没拿到码时,用 (ip+机型+屏幕) 7 天内反查邀请人 |
|
||||
| 邀请奖励金入账/提现 | [`invite_cash_transaction`](./invite_cash_transaction.md) | 独立账本(见上钱包节);余额在 `coin_account.invite_cash_balance_cents` |
|
||||
|
||||
### 美团 CPS 群发联盟(私域社群比价,运营后台驱动 · 群发选品→点击→对账漏斗)
|
||||
| 后台/用户动作 | 表 | 说明 |
|
||||
@@ -66,10 +70,19 @@
|
||||
| 微信内打开落地页授权 | [`cps_wx_user`](./cps_wx_user.md) | 服务号网页授权拿 openid(base 静默)/ 昵称头像 unionid(userinfo,点领券触发),记首次来源群 |
|
||||
| 定时拉美团联盟订单对账 | [`cps_order`](./cps_order.md) | `query_order` 按 sid 归群,串成点击→下单→佣金漏斗 |
|
||||
|
||||
### 埋点 / 首页门面 / 选品缓存
|
||||
| 位置 / 动作 | 表 | 说明 |
|
||||
|---|---|---|
|
||||
| 客户端行为埋点 | [`analytics_event`](./analytics_event.md) | `POST /analytics/events` 批量上报,一事件一行(#83);admin「埋点日志」检索 |
|
||||
| 首页三统计数字 | [`ops_stat_config`](./ops_stat_config.md) | real/manual/random 三模式,admin 配 |
|
||||
| 首页轮播(省钱动态) | [`ops_marquee_seed`](./ops_marquee_seed.md) | 种子条目,与真实 `comparison_record` 混播(数据源模式 mixed/real/seed 可切) |
|
||||
| 首页推荐/销量榜离线选品 | [`meituan_coupon`](./meituan_coupon.md) | 美团 CPS 券缓存,定时 ETL 灌入;`feed?tab=rec` 与 `top-sales` 纯库出、不实时打美团 |
|
||||
|
||||
### 运营后台 admin(独立子应用 `app/admin/`,端口 8771,独立鉴权)
|
||||
| 后台模块 | 表 | 说明 |
|
||||
|---|---|---|
|
||||
| 管理员账号 / 登录 | [`admin_user`](./admin_user.md) | 与 C 端 `user` 完全隔离,独立 JWT + RBAC |
|
||||
| 管理员账号 / 登录 | [`admin_user`](./admin_user.md) | 与 C 端 `user` 完全隔离,独立 JWT + RBAC;`pages_override` 个人可见页覆盖 |
|
||||
| 角色 / 可见页配置 | [`admin_role`](./admin_role.md) | 内建三角色 + 自定义角色(#117/#126);`admin_user.role` 按名引用 |
|
||||
| 操作审计 | [`admin_audit_log`](./admin_audit_log.md) | 每个写操作落一条,只增不改不删 |
|
||||
| 运营可配置项(改奖励常量) | [`app_config`](./app_config.md) | 空表 = 用代码默认;后台改了即覆盖 |
|
||||
| 用户/钱包/提现/反馈管理 | 跨读写上面的 C 端表 | 见下「写入路径」admin 段 |
|
||||
@@ -92,8 +105,8 @@
|
||||
| 签到膨胀 `POST /signin/boost` | `signin_boost_record`(C) + `coin_account`(U) + `coin_transaction`(C `signin_boost`) | 同事务;同日一次 |
|
||||
| 领任务 `POST /tasks/claim` | `user_task`(C) + `coin_account`(U) + `coin_transaction`(C `task_<key>`) | 同事务 |
|
||||
| 金币兑现金 `POST /wallet/exchange` | `coin_account`(U) + `coin_transaction`(C `exchange_out` −) + `cash_transaction`(C `exchange_in` +) | 同事务 |
|
||||
| 发起提现 `POST /wallet/withdraw` | `withdraw_order`(C `reviewing`) + `coin_account`(U 扣现金) + `cash_transaction`(C `withdraw` −) | 同事务,**不打款** |
|
||||
| 查提现状态 / 用户取消 `GET /wallet/withdraw/status` | `withdraw_order`(U) + 失败→`cash_transaction`(C `withdraw_refund` +) | |
|
||||
| 发起提现 `POST /wallet/withdraw` | `withdraw_order`(C `reviewing`,记 `source`) + `coin_account`(U 按 source 扣对应余额) + 流水(C −:`cash_transaction.withdraw` 或 `invite_cash_transaction.invite_withdraw`) | 同事务,**不打款**;#121 按 `source` 分账 |
|
||||
| 查提现状态 / 用户取消 `GET /wallet/withdraw/status` | `withdraw_order`(U) + 失败→对应账本退款流水(C `withdraw_refund` / `invite_withdraw_refund` +) | |
|
||||
| 穿山甲发奖 S2S 回调 `POST /ad/pangle-callback` | `ad_reward_record`(C)+ granted→`coin_account`(U)+`coin_transaction`(C `reward_video`/`signin_boost`) | `trans_id` 幂等 |
|
||||
| 看广告时长上报 `POST /ad/watch-report` | `ad_watch_log`(C) | |
|
||||
| 广告 eCPM 上报 `POST /ad/ecpm-report` | `ad_ecpm_record`(C) | |
|
||||
@@ -101,13 +114,17 @@
|
||||
| 注册设备 / 更新 push token `POST /device/register` | `device_liveness`(C/U upsert) | `(user_id, device_id)` 幂等;只在传入非空时更新 `registration_id`/版本 |
|
||||
| 无障碍服务心跳 `POST /device/heartbeat` | `device_liveness`(C/U) | 心跳也能自注册;`accessibility_enabled=true` 时刷 `last_heartbeat_at`、置 `alive`、清 `notified_at` |
|
||||
| 客户端 ack 掉线提醒 `POST /device/liveness/ack` | `device_liveness`(U) | 清 `kill_alert_pending`(幂等;下次真掉线 worker 再置) |
|
||||
| 比价 done 上报 `POST /compare/record` | `comparison_record`(C 或 U) | `(user_id, trace_id)` 幂等覆盖 |
|
||||
| 比价透传(帧0 / done / finalize)`POST /intent/recognize`、`/price/step`、`/trace/finalize` | `comparison_record`(C `running` → U 终态) | **harvest 三段式**(2026-07):帧0 mint trace_id 建 running 行 → done 写 success/failed → finalize 写 cancelled/failed(不降级 success);best-effort,写失败不连累透传 |
|
||||
| 比价 done 上报 `POST /compare/record`(老客户端兜底) | `comparison_record`(C 或 U) | `trace_id` 幂等覆盖(唯一键已从 `(user_id,trace_id)` 改单列) |
|
||||
| 领里程碑 `POST /compare/milestone/claim` | `comparison_milestone_claim`(C) | **当前不发币**(coin_awarded=0) |
|
||||
| 支付归因上报 `POST /order/report` | `savings_record`(C `source=compare`) | `(user_id, client_event_id)` 幂等 |
|
||||
| 支付归因上报 `POST /order/report` | `savings_record`(C `source=compare`)+ 触发邀请发奖:`invite_relation`(U `compare_reward_granted`)+`coin_account`(U invite_cash)+`invite_cash_transaction`(C `invite_reward`) | `(user_id, client_event_id)` 幂等;#113 邀请发奖口径=好友**比价并下单**,`compare_reward_granted` 幂等闸,同事务 |
|
||||
| 批量埋点 `POST /analytics/events` | `analytics_event`(C 批量) | 不强制登录;每批 ≤200 条 |
|
||||
| 领券任务流水 `POST /coupon/session` | `coupon_session`(C/U upsert) | `trace_id` 幂等:发起建行、收尾更新同一行(#99) |
|
||||
| 重置新手引导 `POST /user/onboarding/reset` | `onboarding_completion`(**D** 该 设备+账号 行) | #114,删完成标记 → 下次登录重走引导 |
|
||||
| 首次进 profile 省钱页且无真实记录 | `savings_record`(C `source=demo`) | 懒种子,`ensure_seeded` 按 user 幂等 |
|
||||
| 上报更低价 `POST /report` | `price_report`(C) | 读 `comparison_record.best_price_cents` 校验 |
|
||||
| 提交反馈 `POST /feedback` | `feedback`(C) | |
|
||||
| 绑定邀请 `POST /invite/bind` | `invite_relation`(C `effective`) + `coin_account`(U×2) + `coin_transaction`(C `invite_inviter` + `invite_invitee`) | 同事务;`invitee_user_id` 唯一幂等,双方各发 1 万金币 |
|
||||
| 绑定邀请 `POST /invite/bind` | `invite_relation`(C `effective`) | `invitee_user_id` 唯一幂等。**#113 起绑定不发奖**——发奖延后到好友比价并下单(`POST /order/report` 行),发的是邀请奖励金非金币 |
|
||||
| 落地页归因 `POST /invite/landing-track` | `invite_fingerprint`(C) | 剪贴板归因兜底线索,登录后用 (ip+机型+屏幕) 反查 |
|
||||
| 领券首帧 `POST /api/v1/coupon/step`(step=0) | `coupon_prompt_engagement`(C/U `claim_started`) | `(device_id, package, 北京日)` 幂等;best-effort |
|
||||
| 领券每帧结果 `POST /api/v1/coupon/step` | `coupon_claim_record`(C/U) | `(device_id, coupon_id, 北京日)` 幂等;best-effort |
|
||||
@@ -121,11 +138,17 @@
|
||||
| 后台操作 | 写入 | 操作 |
|
||||
|---|---|---|
|
||||
| 手动增减金币 | `coin_account`(U)+`coin_transaction`(C `admin_grant`/`admin_deduct`)+`admin_audit_log`(C) | 同事务 |
|
||||
| 改用户状态(禁用/启用) | `user`(U)+`admin_audit_log`(C) | 同事务 |
|
||||
| 审核通过提现 | `withdraw_order`(U→pending/success/failed)+`wechat_transfer_authorization`(C/U)+失败时`cash_transaction`(refund)+`admin_audit_log`(C) | |
|
||||
| 审核拒绝提现 | `withdraw_order`(U→rejected)+`cash_transaction`(C `withdraw_refund`)+`admin_audit_log`(C) | |
|
||||
| 处理反馈 | `feedback`(U)+`admin_audit_log`(C) | 同事务 |
|
||||
| 改运营配置 | `app_config`(C/U)+`admin_audit_log`(C) | 同事务 |
|
||||
| 手动增减现金(#95 支持目标账户) | `coin_account`(U 按 `account` 选列)+ 流水(C:`cash_transaction` 或 `invite_cash_transaction`,`admin_grant`/`admin_deduct`)+`admin_audit_log`(C) | 同事务;`account=coin_cash`/`invite_cash` 两本账各调各 |
|
||||
| 改用户状态(禁用/启用)/ 调试链接权限 | `user`(U `status` / `debug_trace_enabled`)+`admin_audit_log`(C) | 同事务 |
|
||||
| 审核通过提现(含批量) | `withdraw_order`(U→pending/success/failed)+`wechat_transfer_authorization`(C/U)+失败时按 source 退款流水+`admin_audit_log`(C) | |
|
||||
| 审核拒绝提现(含批量) | `withdraw_order`(U→rejected)+按 source 退款流水(C `withdraw_refund`/`invite_withdraw_refund`)+`admin_audit_log`(C) | |
|
||||
| 反馈采纳/拒绝/标记处理 | `feedback`(U→adopted/rejected/handled + `admin_reply`/`reject_reason`)+采纳发币时 `coin_account`(U)+`coin_transaction`(C)+`admin_audit_log`(C) | 同事务(#94/#105) |
|
||||
| 上报更低价审核 | `price_report`(U→approved/rejected)+通过发币 `coin_account`(U)+`coin_transaction`(C)+`admin_audit_log`(C) | 同事务 |
|
||||
| 改运营配置 / 广告配置 / 反馈页二维码 | `app_config`(C/U)+`admin_audit_log`(C) | 同事务 |
|
||||
| 轮播种子/数据源模式 | `ops_marquee_seed`(C/U/D)+`app_config`(模式 mixed/real/seed)+`admin_audit_log`(C) | #122/#123 预览/浏览只读 |
|
||||
| 角色管理(#117/#126) | `admin_role`(C/U/D)+`admin_audit_log`(C) | 内建/在用角色不可删 |
|
||||
| 管理员管理 | `admin_user`(C/U/**D** #126)+`admin_audit_log`(C) | 删除为物理删,不可删自己 |
|
||||
| CPS 建群/活动/生成短链/拉单对账 | `cps_group`/`cps_activity`(C/U/D)、`cps_link`(C)、`cps_order`(C/U upsert;美团 `query_order` + 京东联盟 #90) | 见 CPS 段 |
|
||||
| 任意写操作 | `admin_audit_log`(C,**永不 U/D**) | |
|
||||
|
||||
> 没有任何表会被业务流程物理 DELETE。注销是软删(改 user 行),其余只 C/U(领券 `/prompt/reset`、`/completed-today/reset` 是开发用删除,非业务流程)。
|
||||
@@ -137,7 +160,9 @@
|
||||
| 后台批量生成短链 `POST /admin/api/cps/referral-links` | `cps_link`(C) | 每 群×活动 一条;美团经 `sid` 转链拿 `target_url`(同群同活动重复生成产生多条) |
|
||||
| 用户点群发短链 `GET /c/{code}` / `POST /c/{code}/copy` | `cps_click`(C `visit`/`copy`) | 公开端点不鉴权;`group_id`/`sid` 从 link 冗余进来免 join |
|
||||
| 微信落地页授权回调 `GET /wx/oauth/cb` | `cps_wx_user`(C/U upsert) | 按 `openid` 幂等;base 拿 openid,userinfo 补昵称/unionid(非 None 才覆盖);任何失败兜底回落地页不阻断领券 |
|
||||
| 定时拉美团联盟订单对账 | `cps_order`(C/U upsert) | `query_order` 按 `sid` 归群;`order_id` 幂等(状态会变,重复拉则更新) |
|
||||
| 定时拉联盟订单对账(美团 `query_order` + 京东联盟 #90) | `cps_order`(C/U upsert) | 按 `sid` 归群;`order_id` 幂等(状态会变,重复拉则更新);京东单 `platform='jd'` + `jd_valid_code` 判有效 |
|
||||
| 定时拉穿山甲 GroMore 后台收益(#92,systemd 每天 10:30) | `ad_pangle_daily_revenue`(C/U upsert) | 收益报表/大盘「真实收益」侧;与客户端上报的 eCPM 侧互为对照 |
|
||||
| 定时 ETL 灌美团 CPS 选品库 | `meituan_coupon`(C/U) | `feed?tab=rec` / `top-sales` 纯库出的数据源 |
|
||||
| pricebot 比价 done 内部上报 `POST /internal/price-observation` | `price_observation`(C 批量) | `(trace_id,platform,scope)` 幂等;**不走 JWT、靠 `X-Internal-Secret`**(未配→503) |
|
||||
| pricebot 比价 done 内部上报 `POST /internal/store-mapping` | `store_mapping`(C/U 填空合并) | `trace_id` 幂等;另有 `lookup` 反查 + `invalidate` deeplink 失效标记(淘宝/京东) |
|
||||
| pricebot LLM 兜底放行启动确认窗后上报 `POST /internal/launch-confirm-sample` | `launch_confirm_sample`(C) | **都上报、不去重**;靠 `X-Internal-Secret`(未配→503) |
|
||||
@@ -150,7 +175,7 @@
|
||||
## 三、表间关系 & Join Key
|
||||
|
||||
### 硬外键(数据库 FK 约束)
|
||||
- **18 张用户维度表 `.user_id` → `user.id`**:`coin_account`(同时是 PK)、`coin_transaction`、`cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`(同时是 PK)、`signin_record`、`signin_boost_record`、`user_task`、`comparison_record`、`comparison_milestone_claim`、`savings_record`、`ad_reward_record`、`ad_watch_log`、`ad_ecpm_record`、`ad_feed_reward_record`、`price_report`、`feedback`、`device_liveness`。
|
||||
- **19 张用户维度表 `.user_id` → `user.id`**:`coin_account`(同时是 PK)、`coin_transaction`、`cash_transaction`、`invite_cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`(同时是 PK)、`signin_record`、`signin_boost_record`、`user_task`、`comparison_record`(2026-07 起 `user_id` **可空**——harvest 帧0 建行时软鉴权可能拿不到)、`comparison_milestone_claim`、`savings_record`、`ad_reward_record`、`ad_watch_log`、`ad_ecpm_record`、`ad_feed_reward_record`、`price_report`、`feedback`、`device_liveness`。
|
||||
- `admin_audit_log.admin_id` → `admin_user.id`。
|
||||
- `price_report.comparison_record_id` → `comparison_record.id`(可空:关联记录被删后仍留上报历史)。
|
||||
- **邀请两表** → `user.id`:`invite_relation.inviter_user_id`、`invite_relation.invitee_user_id`(唯一)、`invite_fingerprint.inviter_user_id`——注意 FK 列名是 `inviter`/`invitee_user_id`,不是 `user_id`。
|
||||
@@ -173,13 +198,24 @@
|
||||
|
||||
| biz_type | ref_id 指向 | amount 符号 |
|
||||
|---|---|---|
|
||||
| `withdraw` / `withdraw_refund` | `withdraw_order.out_bill_no` | − / + |
|
||||
| `withdraw` / `withdraw_refund` | `withdraw_order.out_bill_no`(`source=coin_cash` 的单) | − / + |
|
||||
| `exchange_in` | null | + |
|
||||
| `admin_grant` / `admin_deduct` | null(原因在 `remark`=`admin:<reason>`) | + / − |
|
||||
|
||||
- **`invite_cash_transaction.ref_id`**(邀请奖励金账本,#82):
|
||||
|
||||
| biz_type | ref_id 指向 | amount 符号 |
|
||||
|---|---|---|
|
||||
| `invite_reward` | 被邀请人 `user.id`(字符串) | + |
|
||||
| `invite_withdraw` / `invite_withdraw_refund` | `withdraw_order.out_bill_no`(`source=invite_cash` 的单) | − / + |
|
||||
| `admin_grant` / `admin_deduct` | null | + / − |
|
||||
|
||||
- **`comparison_record.store_name` ≈ `savings_record.shop_name`**:无 id 关联,按**店名字符串相等**给比价记录打「已下单」标记(瞬态,不写库)。两边店名同源 = 比价意图识别阶段的门店 query,语义=**店级**(同店比价多次会一并标已下单)。
|
||||
- **广告流会话关联**:`ad_reward_record.ad_session_id` 可与 `ad_ecpm_record.ad_session_id` 对齐;`ad_watch_log` 仍是旧版兼容统计,不逐条参与发奖。
|
||||
- **里程碑解锁进度不存库**:`comparison_milestone_claim` 只记「哪几档已领」;进度 = `comparison_record` 里 `status='success'` 的 `count`。
|
||||
- **领券三表无硬 FK,全靠软关联**:`coupon_prompt_engagement` / `coupon_daily_completion` / `coupon_claim_record` 的 `user_id` **软指** `user.id`(可空、有登录态才记、不进唯一键、不阻塞判断);`trace_id` **软指** pricebot work_logs(排查回指);唯一键都以 `device_id` + 北京自然日为主(详见 [`coupon_state.md`](./coupon_state.md))。
|
||||
- **领券三表无硬 FK,全靠软关联**:`coupon_prompt_engagement` / `coupon_daily_completion` / `coupon_claim_record` 的 `user_id` **软指** `user.id`(可空、有登录态才记、不进唯一键、不阻塞判断);`trace_id` **软指** pricebot work_logs(排查回指);唯一键都以 `device_id` + 北京自然日为主(详见 [`coupon_state.md`](./coupon_state.md))。[`coupon_session`](./coupon_session.md)(#99)同口径:`trace_id` 唯一 upsert、`user_id` 软指(admin 明细 LEFT JOIN 出手机号)。
|
||||
- **`analytics_event`**(#83)与 **`coupon_session`** 均无硬 FK:埋点/流水不鉴权也收,`device_id`/`user_id` 只作维度。
|
||||
- **`admin_user.role` 按名语义引用 `admin_role.name`**(无硬 FK,#117):删除保护在应用层(在用/内建角色不可删);个人 `pages_override` 优先于角色 `pages`。
|
||||
- **`onboarding_completion.(user_id, device_id)`**:`user_id` 语义关联 `user.id`(无硬 FK,同 `coupon_*` 设备表),`device_id` = 客户端硬件级 `ANDROID_ID`(≠ 领券 per-install `device_id`)。登录读、走完引导写,决定是否再展示新手引导。
|
||||
- **CPS 群发 6 表全靠 `sid` / id / `openid` 语义串联(无硬 FK)**:`cps_link.group_id`→`cps_group.id`、`cps_link.activity_id`→`cps_activity.id`、`cps_click.link_id`→`cps_link.id`、`cps_wx_user.first_group_id`→`cps_group.id`;**点击与订单无法对到单笔**,只在群维度(`sid`)汇合——`cps_order.sid` ≈ `cps_group.sid` ≈ `cps_link.sid` ≈ `cps_click.sid`(仅美团有 sid,淘宝/京东无)。统计按群聚合,故 `group_id`/`sid` 冗余进 `cps_click` 免 join。`cps_wx_user` 按 `openid` 自成用户身份维度,与 `cps_click`/`cps_order` 无 id 级 join。
|
||||
- **比价沉淀两表(`price_observation` / `store_mapping`)**:`trace_id` 软指 pricebot work_logs(与 `comparison_record.trace_id` 同源但不互 join,各存各视角);`source_user_id` / `source_device_id` 软指用户/设备(可空,匿名也记)。`store_mapping.lookup` 靠**店名字符串精确相等** + geo 取最近,非 id 级 join。
|
||||
@@ -188,10 +224,10 @@
|
||||
```
|
||||
user ─1:1─ coin_account
|
||||
user ─1:1─ wechat_transfer_authorization
|
||||
user ─1:N─ { coin_transaction, cash_transaction, withdraw_order, signin_record,
|
||||
signin_boost_record, user_task, comparison_record, comparison_milestone_claim,
|
||||
savings_record, ad_reward_record, ad_watch_log, ad_ecpm_record, ad_feed_reward_record,
|
||||
price_report, feedback, device_liveness }
|
||||
user ─1:N─ { coin_transaction, cash_transaction, invite_cash_transaction, withdraw_order,
|
||||
signin_record, signin_boost_record, user_task, comparison_record(user_id 可空),
|
||||
comparison_milestone_claim, savings_record, ad_reward_record, ad_watch_log,
|
||||
ad_ecpm_record, ad_feed_reward_record, price_report, feedback, device_liveness }
|
||||
(device_liveness 硬 FK; (user_id,device_id) 唯一)
|
||||
user ─1:N─ onboarding_completion (user_id, 无硬 FK; (user_id,device_id) 去重)
|
||||
comparison_record ─1:N─ price_report (comparison_record_id, 可空)
|
||||
@@ -199,6 +235,11 @@ admin_user ─1:N─ admin_audit_log
|
||||
app_config (独立, 无外键, key 为主键)
|
||||
coupon_prompt_engagement / coupon_daily_completion / coupon_claim_record
|
||||
(独立, 无硬 FK; 维度=device_id+北京日, user_id/trace_id 仅软关联)
|
||||
coupon_session (独立, 无硬 FK; trace_id 唯一=一次领券任务一行, #99)
|
||||
analytics_event (独立, 无硬 FK; 埋点事件流, device/user 仅维度, #83)
|
||||
admin_role ◀──语义(role 按 name 引用, 无FK)── admin_user (pages_override 个人覆盖, #117/#126)
|
||||
ops_stat_config / ops_marquee_seed / meituan_coupon / ad_pangle_daily_revenue
|
||||
(独立运营/缓存/对账表, 无外键)
|
||||
user ─1:N─ invite_relation (inviter_user_id 硬 FK); invitee_user_id ─1:1─ user (唯一硬 FK)
|
||||
user ─1:N─ invite_fingerprint (inviter_user_id 硬 FK)
|
||||
cps_activity / cps_group ──语义(无FK)──▶ cps_link ─1:N─ cps_click
|
||||
@@ -212,13 +253,13 @@ launch_confirm_sample (独立, 无硬 FK; 都上报不去
|
||||
|
||||
## 四、资金模型(金币 / 现金 / 提现,三层)
|
||||
|
||||
1. **余额快照** `coin_account`:`coin_balance`(金币个数)+ `cash_balance_cents`(现金分),一用户一行,读取展示用。
|
||||
2. **流水账本** `coin_transaction` / `cash_transaction`:每次变动写一笔,`balance_after*` 记变动后余额,可逐笔回溯对账。
|
||||
3. **唯一发金币入口** `repositories/wallet.grant_coins`:更新快照 + 写流水,**不 commit**,由调用方在同一事务里 commit(保证"记录"和"加币"原子化)。signin / signin_boost / task / ad_reward / feed_ad_reward / exchange / admin 都走它,靠 `biz_type` 区分来源。
|
||||
1. **余额快照** `coin_account`:`coin_balance`(金币个数)+ `cash_balance_cents`(现金分)+ `invite_cash_balance_cents`(邀请奖励金分,#82),一用户一行,读取展示用。
|
||||
2. **流水账本** `coin_transaction` / `cash_transaction` / `invite_cash_transaction`:每次变动写一笔,`balance_after*` 记变动后余额,可逐笔回溯对账。**现金与邀请奖励金是两本物理隔离的账**——发放口径与提现对账各自独立。
|
||||
3. **唯一变动入口**:金币走 `repositories/wallet.grant_coins`,邀请奖励金走 `grant_invite_cash`——都是「更新快照 + 写流水,**不 commit**,由调用方同一事务 commit」。signin / signin_boost / task / ad_reward / feed_ad_reward / exchange / admin 走 `grant_coins`;`invite_reward` / admin 调整走 `grant_invite_cash`,靠 `biz_type` 区分来源。
|
||||
|
||||
- **汇率**:`10000 金币 = 1 元 = 100 分`(`rewards.COIN_PER_YUAN`);兑换额必须是整分倍数。
|
||||
- **提现状态机**:`reviewing`(发起即原子扣现金、待人工审核、**不打款**)→ 审核通过 `pending`(微信转账在途)→ `success` / `failed`(失败自动退款);审核拒绝 `rejected`(退款)。扣款/退款都写 `cash_transaction`,`out_bill_no` 幂等,孤儿 pending 单由 `reconcile_pending_withdraws` 对账兜底。
|
||||
- **防超额**:扣现金用带条件 `UPDATE ... WHERE cash_balance_cents >= amount`,并发/重试不会双扣。
|
||||
- **提现状态机**:`reviewing`(发起即原子扣款、待人工审核、**不打款**)→ 审核通过 `pending`(微信转账在途)→ `success` / `failed`(失败自动退款);审核拒绝 `rejected`(退款)。**按 `withdraw_order.source` 分账**(#121):`coin_cash` 单的扣款/退款写 `cash_transaction`,`invite_cash` 单写 `invite_cash_transaction`;`out_bill_no` 幂等,孤儿 pending 单由 `reconcile_pending_withdraws` 对账兜底,admin `withdraws/ledger-check` 分账校验「单 ↔ 流水」。
|
||||
- **防超额**:扣款用带条件 `UPDATE ... WHERE <对应余额列> >= amount`(按 source 选 `cash_balance_cents` / `invite_cash_balance_cents`),并发/重试不会双扣。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+16
-7
@@ -3,13 +3,13 @@
|
||||
> 数据库:SQLite 起步(`data/app.db`),生产可切 PostgreSQL(改 `DATABASE_URL`)。
|
||||
> ORM:SQLAlchemy 2.0(`app/models/`),迁移:Alembic(`alembic/versions/`,`render_as_batch` 兼容 SQLite)。
|
||||
> 金额字段一律存**整数**:金币=个数,现金=**分**(`*_cents`)。时间列 `DateTime(timezone=True)`。
|
||||
> 最后更新:2026-06-23(补 3 张表文档:`device_liveness`(#65 无障碍存活监控)、`cps_wx_user`(CPS 落地页微信身份)、`launch_confirm_sample`(启动确认窗兜底样本);`comparison_record` 补 `input_tokens`/`output_tokens`。上一次 2026-06-17 补全 CPS 群发 5 张 `cps_*` + 好友邀请 2 张 `invite_*` + 比价沉淀 `price_observation`/`store_mapping` 并全表 review 对齐 model;含 [OVERVIEW 总览](./OVERVIEW.md))
|
||||
> 最后更新:2026-07-09(补 5 张新表文档并入索引:`coupon_session`(#99 领券任务流水)、`analytics_event`(#83 埋点)、`invite_cash_transaction`(#82 邀请奖励金账本)、`admin_role`(#117/#126 RBAC 角色)、`ad_pangle_daily_revenue`(#92,文档已有、补进索引);同步改动列:`comparison_record.product_names`、`withdraw_order.source`、`coin_account.invite_cash_balance_cents`、`feedback` 审核/环境列、`cps_order` 京东列、`device_liveness.first_protected_at`、`ad_feed_reward_record.ad_type/feed_scene`、`admin_user.plain_password/pages_override`。上一次 2026-06-23)
|
||||
|
||||
> 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。
|
||||
|
||||
---
|
||||
|
||||
## 表总览(40 张业务表 + `alembic_version` 框架表)
|
||||
## 表总览(45 张业务表 + `alembic_version` 框架表)
|
||||
|
||||
### 账号 / 反馈
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
@@ -22,7 +22,7 @@
|
||||
### 好友邀请(注册增长)
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
|---|---|---|---|
|
||||
| `invite_relation` | 邀请绑定关系(注册即生效,双方各发1万金币;`invitee_user_id` 唯一=幂等防重复发奖) | `models/invite.py` | [详情](./invite_relation.md) |
|
||||
| `invite_relation` | 邀请绑定关系(注册即生效但**绑定不发奖** #113;好友比价并下单后发邀请奖励金,`compare_reward_granted` 幂等闸;`invitee_user_id` 唯一) | `models/invite.py` | [详情](./invite_relation.md) |
|
||||
| `invite_fingerprint` | 剪贴板归因失败时的指纹兜底(落地页记 ip+屏幕+机型,登录后反查邀请人) | `models/invite_fingerprint.py` | [详情](./invite_fingerprint.md) |
|
||||
|
||||
### 钱包 / 福利(看广告赚钱闭环)
|
||||
@@ -30,8 +30,9 @@
|
||||
|---|---|---|---|
|
||||
| `coin_account` | 金币+现金余额快照(一用户一行) | `models/wallet.py` | [详情](./coin_account.md) |
|
||||
| `coin_transaction` | 金币流水账本 | `models/wallet.py` | [详情](./coin_transaction.md) |
|
||||
| `cash_transaction` | 现金流水账本(分) | `models/wallet.py` | [详情](./cash_transaction.md) |
|
||||
| `withdraw_order` | 提现单(现金→微信零钱,含人工审核态) | `models/wallet.py` | [详情](./withdraw_order.md) |
|
||||
| `cash_transaction` | 现金流水账本(分,金币兑换账) | `models/wallet.py` | [详情](./cash_transaction.md) |
|
||||
| `invite_cash_transaction` | 邀请奖励金流水账本(分,与现金物理隔离;好友比价并下单发奖 + `source=invite_cash` 提现) | `models/wallet.py` | [详情](./invite_cash_transaction.md) |
|
||||
| `withdraw_order` | 提现单(现金→微信零钱,含人工审核态;`source` 分账 coin_cash/invite_cash) | `models/wallet.py` | [详情](./withdraw_order.md) |
|
||||
| `wechat_transfer_authorization` | 微信免确认转账授权(一用户一行) | `models/wallet.py` | [详情](./wechat_transfer_authorization.md) |
|
||||
| `signin_record` | 签到记录(7 天循环) | `models/signin.py` | [详情](./signin_record.md) |
|
||||
| `signin_boost_record` | 签到后看广告膨胀记录 | `models/signin.py` | [详情](./signin_boost_record.md) |
|
||||
@@ -39,7 +40,8 @@
|
||||
| `ad_reward_record` | 看激励视频发奖记录(S2S 回调,trans_id 幂等) | `models/ad_reward.py` | [详情](./ad_reward_record.md) |
|
||||
| `ad_watch_log` | 看广告观看时长(旧版兼容字段) | `models/ad_watch_log.py` | [详情](./ad_watch_log.md) |
|
||||
| `ad_ecpm_record` | 广告展示 eCPM 上报(收益对账) | `models/ad_ecpm.py` | [详情](./ad_ecpm_record.md) |
|
||||
| `ad_feed_reward_record` | 信息流广告结算记录(10 秒一份,client_event_id 幂等) | `models/ad_feed_reward.py` | [详情](./ad_feed_reward_record.md) |
|
||||
| `ad_feed_reward_record` | 信息流/Draw 广告结算记录(10 秒一份,client_event_id 幂等;`ad_type`+`feed_scene` 分形态/场景) | `models/ad_feed_reward.py` | [详情](./ad_feed_reward_record.md) |
|
||||
| `ad_pangle_daily_revenue` | 穿山甲 GroMore 后台收益日表(定时拉取,收益报表/大盘真实收益源,#92) | `models/ad_pangle_revenue.py` | [详情](./ad_pangle_daily_revenue.md) |
|
||||
|
||||
### 比价 / 省钱
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
@@ -62,6 +64,7 @@
|
||||
| `coupon_prompt_engagement` | 领券引导窗频控源(今日是否已 engage,按 device+package+日) | `models/coupon_state.py` | [详情](./coupon_state.md) |
|
||||
| `coupon_daily_completion` | 首页「去领取」置灰源(今日是否已跑完整轮) | `models/coupon_state.py` | [详情](./coupon_state.md) |
|
||||
| `coupon_claim_record` | 每张券领取结果沉淀(资产/画像/排查,不参与判断) | `models/coupon_state.py` | [详情](./coupon_state.md) |
|
||||
| `coupon_session` | 一次领券任务一行的全程流水(发起/终态/耗时/机型,admin 领券看板数据源,#99) | `models/coupon_state.py` | [详情](./coupon_session.md) |
|
||||
|
||||
### 美团 CPS 券缓存
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
@@ -84,10 +87,16 @@
|
||||
| `ops_stat_config` | 首页三统计展示配置(real/manual/random) | `models/ops_stat_config.py` | [详情](./ops_stat_config.md) |
|
||||
| `ops_marquee_seed` | 首页轮播种子(真实不足时兜底混播) | `models/ops_marquee_seed.py` | [详情](./ops_marquee_seed.md) |
|
||||
|
||||
### 埋点
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
|---|---|---|---|
|
||||
| `analytics_event` | 客户端埋点事件流(批量上报,一事件一行;admin 埋点日志检索,#83) | `models/analytics_event.py` | [详情](./analytics_event.md) |
|
||||
|
||||
### 运营后台 admin(独立子应用 `app/admin/`,独立鉴权)
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
|---|---|---|---|
|
||||
| `admin_user` | 管理员账号(独立 JWT + RBAC) | `models/admin.py` | [详情](./admin_user.md) |
|
||||
| `admin_user` | 管理员账号(独立 JWT + RBAC;`pages_override` 个人可见页覆盖) | `models/admin.py` | [详情](./admin_user.md) |
|
||||
| `admin_role` | 后台角色→可见页配置(内建三角色 + 自定义角色,#117/#126) | `models/admin_role.py` | [详情](./admin_role.md) |
|
||||
| `admin_audit_log` | 操作审计日志(只追加) | `models/admin.py` | [详情](./admin_audit_log.md) |
|
||||
| `app_config` | 运营可配置项(覆盖 rewards 常量) | `models/app_config.py` | [详情](./app_config.md) |
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
| `id` | Integer | PK | 自增主键 |
|
||||
| `client_event_id` | String(64) | UNIQUE, NOT NULL | 客户端幂等事件 id |
|
||||
| `ad_session_id` | String(64) | index, nullable | 客户端生成的一次信息流广告会话 id |
|
||||
| `ad_type` | String(16) | nullable, default `feed` | 广告形态:`feed`(信息流)/ `draw`(Draw 视频流)。旧数据 NULL 视为 feed(迁移随 #83 入库) |
|
||||
| `feed_scene` | String(16) | nullable | 展示场景:`compare`(比价期)/ `coupon`(领券期)。比价与领券共用同一 Draw 代码位,收益/大盘按它分场景归集(#125 修比价/领券奖励金币恒 0 即改按本列汇总) |
|
||||
| `user_id` | Integer | FK → `user.id`, index, NOT NULL | 用户 |
|
||||
| `reward_date` | String(10) | index, NOT NULL | 北京时间日期 `YYYY-MM-DD` |
|
||||
| `duration_seconds` | Integer | NOT NULL | 整场比价累计观看秒数(轮播各条相加) |
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# admin_role — 运营后台角色(RBAC 可见页配置)
|
||||
|
||||
> 模型 `app/models/admin_role.py` · 仓库 `app/admin/repositories/admin_role.py` · 权限目录 `app/admin/permissions.py` · 接口 admin `GET/POST /admin/api/roles`、`PATCH/DELETE /admin/api/roles/{id}`、`GET /admin/api/roles/catalog`(`app/admin/routers/roles.py`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
运营后台的**角色 → 可见页面**配置表。#117 把 RBAC 从「代码写死三角色」升级为数据驱动:内建角色(`super_admin`/`finance`/`operator`)种子进表(`is_builtin=true`,不可删),`super_admin` 可另建**自定义角色**(#126)勾选任意页面组合。`admin_user.role` 存角色 `name` 按名引用本表;单个管理员还可用 `admin_user.pages_override` 在角色之上覆盖个人可见页。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C(插入)**:内建三角色随迁移种子;`POST /admin/api/roles`(super_admin)新建自定义角色。
|
||||
- **U(更新)**:`PATCH /admin/api/roles/{id}` 改展示名/可见页(内建角色的 `pages` 也可调)。
|
||||
- **D(删除)**:`DELETE /admin/api/roles/{id}`——**内建角色与在用角色(有 admin_user.role 引用)不可删**。
|
||||
- **R**:登录/每次 admin 鉴权时按 `admin_user.role` 查本表算有效可见页(`pages_override` 非空则以覆盖为准);`GET /admin/api/roles/catalog` 返回全部可配页面目录(分组,来自 `permissions.py` 常量,不落库)。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `name` | String(32) | UNIQUE, index, NOT NULL | 角色标识(被 `admin_user.role` 按名引用):内建 `super_admin`/`finance`/`operator`;**自定义角色 name(key)= label = 创建时的输入名称**(不能叫 super_admin,重名 409) |
|
||||
| `label` | String(32) | NOT NULL, default `""` | 展示名(后台下拉里显示) |
|
||||
| `pages` | JSON | NOT NULL, default `[]` | 可见页面 key 列表(全集见 `permissions.py` 目录;前端按它渲染菜单,后端接口守卫同源校验)。**`super_admin` 行 pages 存空数组**,有效可见页特判为全部 |
|
||||
| `is_builtin` | Boolean | NOT NULL, default false | 内建角色标记(不可删;`super_admin` 恒过所有守卫,不依赖 pages) |
|
||||
| `created_at` | DateTime(tz) | server_default now() | |
|
||||
|
||||
## 关系 / Join Key
|
||||
- ← `admin_user.role` **按 `name` 语义引用**(无硬 FK;删除保护在应用层:在用角色不可删)。
|
||||
- 与 `admin_user.pages_override` 的关系:有效可见页 = `pages_override`(个人覆盖,非空优先)∪ 否则取角色 `pages`;`super_admin` 无视两者恒全通。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`;UNIQUE+index `name`。
|
||||
|
||||
## 注意
|
||||
- 页面 key 目录维护在代码 `app/admin/permissions.py`(加新后台页面要同步登记),表里只存勾选结果——目录变更不需要迁移。
|
||||
- 角色守卫兼容旧语义:`require_role("finance")` 等旧代码路径仍工作,内建角色名不可改。
|
||||
@@ -1,13 +1,13 @@
|
||||
# admin_user — 运营后台管理员账号
|
||||
|
||||
> 模型 `app/models/admin.py` · 仓库 `app/admin/repositories/admin_user.py` · 接口 [admin-auth-login](../api/admin-auth-login.md) / [admin-admins-list](../api/admin-admins-list.md) / [admin-admin-create](../api/admin-admin-create.md) / [admin-admin-update](../api/admin-admin-update.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
> 模型 `app/models/admin.py` · 仓库 `app/admin/repositories/admin_user.py` · 接口 [admin-auth-login](../api/admin/auth/admin-auth-login.md) / [admin-admins-list](../api/admin/admins/admin-admins-list.md) / [admin-admin-create](../api/admin/admins/admin-admin-create.md) / [admin-admin-update](../api/admin/admins/admin-admin-update.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
运营后台(`app/admin/` 子应用,端口 8771)的管理员账号,与 App 用户(`user` 表)**完全隔离**:独立 JWT secret、独立鉴权链。密码 bcrypt 存哈希,带角色做 RBAC 权限分级。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C(插入)**:① 首个管理员用 `scripts/create_admin.py` 命令行创建(无自助注册);② `super_admin` 在后台「管理员管理」`POST` 新建子管理员。
|
||||
- **U(更新)**:登录成功刷 `last_login_at`;`super_admin` 改他人 `role`/`status`/重置密码(`admin-admin-update`)。
|
||||
- **D**:无(禁用走 `status='disabled'`,token 立即失效)。
|
||||
- **U(更新)**:登录成功刷 `last_login_at`;`super_admin` 改他人 `role`/`status`/重置密码/`pages_override`(`admin-admin-update`)。
|
||||
- **D**:`DELETE /admin/api/admins/{id}`(#126,super_admin,带审计;不可删自己)。禁用仍走 `status='disabled'`(token 立即失效)。
|
||||
- **R**:每个 admin 请求经 `admin/deps` 解 admin token 查本表(校验 `status=='active'` + 角色守卫);管理员列表。
|
||||
|
||||
## 字段
|
||||
@@ -15,8 +15,10 @@
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | 被 `admin_audit_log.admin_id` 引用 |
|
||||
| `username` | String(64) | UNIQUE, index, NOT NULL | 登录名 |
|
||||
| `password_hash` | String(255) | NOT NULL | bcrypt 哈希(明文不落库;⚠️ bcrypt 72 字节截断) |
|
||||
| `role` | String(20) | NOT NULL, default `operator` | 取值:`super_admin`(全权+管账号)/ `finance`(钱:提现+金币)/ `operator`(用户+反馈+大盘) |
|
||||
| `password_hash` | String(255) | NOT NULL | bcrypt 哈希(⚠️ bcrypt 72 字节截断) |
|
||||
| `plain_password` | String(128) | nullable | **明文密码副本**(#117,`super_admin` 在管理员列表可见,便于线下派发/找回;创建/重置密码时同步写)。安全上是有意取舍:后台仅内网+super_admin 可见 |
|
||||
| `role` | String(20) | NOT NULL, default `operator` | 角色名,按 `name` 引用 [`admin_role`](./admin_role.md)(#117 起数据驱动):内建 `super_admin`(恒全权)/ `finance` / `operator`,或自定义角色(#126) |
|
||||
| `pages_override` | JSON | nullable | **个人可见页覆盖**(#126):非空时优先于角色 `pages`;NULL=跟随角色。页面 key 目录见 `app/admin/permissions.py` |
|
||||
| `status` | String(20) | NOT NULL, default `active` | 取值:`active` / `disabled`(禁用后 token 立即失效) |
|
||||
| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 创建时间 |
|
||||
| `last_login_at` | DateTime(tz) | nullable | 最近登录时间(登录成功时更新) |
|
||||
@@ -30,4 +32,4 @@
|
||||
|
||||
## 注意
|
||||
- **鉴权隔离**:admin token `typ=admin` + 独立 `ADMIN_JWT_SECRET`(≠ App 的 `JWT_SECRET_KEY`),App 用户 token 无法当 admin 用;admin 无 refresh,过期(默认 12h)重登。
|
||||
- **RBAC**:`super_admin` 恒过所有角色守卫(`require_role`);`finance` 管钱、`operator` 管用户/反馈/大盘。具体守卫见各接口文档。
|
||||
- **RBAC**:`super_admin` 恒过所有角色守卫(`require_role`);`finance` 管钱、`operator` 管用户/反馈/大盘;#117 起可见页由 [`admin_role`](./admin_role.md)`.pages` 数据驱动 + `pages_override` 个人覆盖,自定义角色见 `POST /admin/api/roles`。具体守卫见各接口文档。
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# analytics_event — 客户端埋点事件流
|
||||
|
||||
> 模型 `app/models/analytics_event.py` · 仓库 `app/repositories/analytics.py` · 接口 C 端 `POST /api/v1/analytics/events`([analytics-events](../api/other/analytics-events.md),批量,不强制登录);admin `GET /admin/api/event-logs`(`app/admin/routers/event_logs.py`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
客户端行为埋点的落地表,**一事件一行**(append-only)。客户端攒批上报(每批 ≤200 条),服务端展开逐条插入;`props` 装事件自带的任意维度。当前主要供 admin「埋点日志」检索与手工分析,无自动聚合任务。#83 新增(2026-06-27)。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C(插入)**:`POST /api/v1/analytics/events`(不强制登录:带 JWT 则记 `user_id`,匿名只记 `device_id`)。服务端补 `client_ip`。
|
||||
- **U / D**:无。只追加。
|
||||
- **R**:admin `GET /admin/api/event-logs`(游标分页,可按 `event`/`device_id`/`user_id` 筛)。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `event` | String(64) | index, NOT NULL | 事件名(客户端约定,如页面曝光/点击类事件名) |
|
||||
| `props` | JSON | nullable | 事件属性(任意维度,客户端原样传) |
|
||||
| `device_id` | String(64) | index, NOT NULL | 客户端 per-install id(匿名也有) |
|
||||
| `user_id` | Integer | index, nullable | 登录态才有。**无硬 FK** |
|
||||
| `session_id` | String(64) | index, nullable | 客户端会话 id(一次冷启动一个) |
|
||||
| `client_ts` | BigInteger | NOT NULL | 事件发生时间(客户端 epoch ms) |
|
||||
| `sent_at` | BigInteger | nullable | 客户端上报时间(epoch ms);与 `client_ts` 差 = 攒批延迟 |
|
||||
| `page` | String(64) | nullable | 事件所在页面 |
|
||||
| `client_ip` | String(64) | nullable | 服务端从请求头取 |
|
||||
| `oem` / `os` / `model` | String | nullable | 厂商 / 系统版本 / 机型 |
|
||||
| `app_ver` | String(32) | nullable | App versionName |
|
||||
| `network` | String(16) | nullable | 网络类型(wifi/cellular) |
|
||||
| `channel` | String(32) | nullable | 分发渠道 |
|
||||
| `created_at` | DateTime(tz) | server_default now() | 入库时间 |
|
||||
|
||||
## 关系 / Join Key
|
||||
- `user_id` 软指 `user.id`、`device_id` 与其他表的 per-install id 同源——均无硬 FK(埋点不鉴权、匿名也收,不能被外键约束卡住)。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`;index `event`、`device_id`、`user_id`、`session_id`。
|
||||
|
||||
## 注意
|
||||
- 每批最多 200 条,超出整批 422;单条字段超长按模型截断口径处理。
|
||||
- 时间轴分析用 `client_ts`(事件真实发生时刻),`created_at` 只是入库时刻(受攒批影响)。
|
||||
@@ -1,17 +1,20 @@
|
||||
# cash_transaction — 现金流水账本(分)
|
||||
|
||||
> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-cash-transactions](../api/wallet-cash-transactions.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-cash-transactions](../api/wallet/wallet-cash-transactions.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
现金每变动一笔就记一行(单位:**分**,记变动后余额)。金币兑现金、提现、提现退款都落这里。**只增不改不删**。
|
||||
|
||||
> **只管「金币兑换现金」这本账**(`coin_account.cash_balance_cents`)。**邀请奖励金**是另一本物理隔离的账 → [`invite_cash_transaction`](./invite_cash_transaction.md)(同构表);`source=invite_cash` 的提现流水**不落本表**。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C(插入)**:三个来源,每次写一笔:
|
||||
- **C(插入)**:四个来源,每次写一笔:
|
||||
|
||||
| 动作 / endpoint | `biz_type` | `amount_cents` | `ref_id` 指向 |
|
||||
|---|---|---|---|
|
||||
| 金币兑现金 `POST /wallet/exchange` | `exchange_in` | + | null(配套 `coin_transaction.exchange_out`) |
|
||||
| 发起提现 `POST /wallet/withdraw` | `withdraw` | −(扣现金) | `withdraw_order.out_bill_no` |
|
||||
| 发起提现 `POST /wallet/withdraw`(`source=coin_cash`) | `withdraw` | −(扣现金) | `withdraw_order.out_bill_no` |
|
||||
| 提现失败/取消/审核拒绝退款 | `withdraw_refund` | +(退回) | `withdraw_order.out_bill_no` |
|
||||
| admin 手动调整 `POST /admin/api/users/{id}/cash`(`account=coin_cash`) | `admin_grant` / `admin_deduct` | + / − | null(原因记 `remark`=`admin:<reason>`) |
|
||||
|
||||
- **U / D**:无。账本只追加。
|
||||
- **R**:`GET /wallet/cash-transactions`(现金明细,`id` 倒序游标);admin 跨用户现金流水。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# coin_account — 金币 + 现金余额快照
|
||||
|
||||
> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-account](../api/wallet-account.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-account](../api/wallet/wallet-account.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
一用户一行的余额快照,App「资产卡 / 钱包」读它展示。每次余额变动都另写一笔流水(`coin_transaction` / `cash_transaction`)并记 `balance_after`,出问题逐笔回溯。`user_id` 既是主键也是外键(一对一)。详见 [总览 §四 资金模型](./OVERVIEW.md#四资金模型金币--现金--提现三层)。
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
|---|---|---|---|
|
||||
| `user_id` | Integer | **PK + FK→user.id** | 用户(一用户一行);既是主键也是外键 |
|
||||
| `coin_balance` | Integer | NOT NULL, default 0 | 当前金币余额(个数);= 历次 `coin_transaction.amount` 之和 |
|
||||
| `cash_balance_cents` | Integer | NOT NULL, default 0 | 当前现金余额(分);= 历次 `cash_transaction.amount_cents` 之和 |
|
||||
| `cash_balance_cents` | Integer | NOT NULL, default 0 | 当前现金余额(分,**金币兑换账本**);= 历次 `cash_transaction.amount_cents` 之和 |
|
||||
| `invite_cash_balance_cents` | Integer | NOT NULL, default 0 | 当前**邀请奖励金**余额(分,与现金物理隔离的第二本现金账);= 历次 [`invite_cash_transaction`](./invite_cash_transaction.md)`.amount_cents` 之和。好友比价并下单发奖入账(#113),`source=invite_cash` 提现出账(#121) |
|
||||
| `total_coin_earned` | Integer | NOT NULL, default 0 | 累计赚取金币(**只增不减**,仅正向 grant 累加),用于"历史总收益"展示 |
|
||||
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 最后更新时间 |
|
||||
|
||||
@@ -27,5 +28,5 @@
|
||||
- PK `user_id`(同时是 FK→user.id)。
|
||||
|
||||
## 注意
|
||||
- **唯一发金币入口** `wallet.grant_coins`:更新本表 + 写 `coin_transaction`,**不 commit**,由调用方同事务提交(发币与业务记录原子化)。
|
||||
- 扣现金用 `UPDATE ... WHERE cash_balance_cents >= amount` 原子条件扣减,并发/重试不会超额。
|
||||
- **唯一发金币入口** `wallet.grant_coins`:更新本表 + 写 `coin_transaction`,**不 commit**,由调用方同事务提交(发币与业务记录原子化)。邀请奖励金同款:**唯一变动入口 `wallet.grant_invite_cash`**(更新 `invite_cash_balance_cents` + 写 `invite_cash_transaction`)。
|
||||
- 扣现金用 `UPDATE ... WHERE <对应余额列> >= amount` 原子条件扣减(按提现 `source` 选 `cash_balance_cents` 或 `invite_cash_balance_cents`),并发/重试不会超额。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# comparison_record — 比价记录(每次比价完整明细)
|
||||
|
||||
> 模型 `app/models/comparison.py` · 仓库 `app/repositories/comparison.py` · 接口 [compare-record-report](../api/compare-record-report.md) / [compare-records](../api/compare-records.md) / [compare-record-detail](../api/compare-record-detail.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
> 模型 `app/models/comparison.py` · 仓库 `app/repositories/comparison.py` · 接口 [compare-record-report](../api/compare/compare-record-report.md) / [compare-records](../api/compare/compare-records.md) / [compare-record-detail](../api/compare/compare-record-detail.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
每完成一次比价(外卖/电商/领券)记一行。**写入以 app-server 后端 harvest 为主**(2026-07 起):比价透传壳 `compare.py` 在帧0(pricebot 出 trace_id)即建 `running` 行,随 done / `trace/finalize` 逐步补全成终态,客户端不再主动 POST 记录;老客户端仍可走**带 JWT** 的 `POST /compare/record` 兜底(灰度期两条写路径按 `trace_id` reconcile)。App「我的比价记录」列表/详情的数据源,也是比价战绩里程碑解锁进度的计数源(`status='success'` 条数),还被「上报更低价」反查原最低价。
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
> 与 [`price_observation`](./price_observation.md) / `store_mapping` 的区别:本表是**用户视角**(登录后按 `user_id` 存「我的比价记录」);后两张是 server 侧无条件沉淀的**平台/门店视角客观事实**(价格事实 / 跨平台店铺身份映射),与本表 `trace_id` 同源但不互相 join,各存各的视角。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C / U(harvest 为主,按 `trace_id` 幂等)**:透传壳 `compare.py` 三段式落库(`app/repositories/comparison.py`)——`harvest_running`(帧0 建 `running` 行)→ `harvest_done`(done 帧转 `success`/`failed` + 派生 `best_*`/`saved_amount_cents` + 返 `newly_success` 供幂等发邀请奖)→ `harvest_abort`(`trace/finalize` 转 `cancelled`/`failed`,**不降级已 success**)。老客户端仍可 `POST /compare/record`(`upsert_record`,按 `trace_id` 查、整行覆盖)兜底。`best_*`/`saved_amount_cents`/`is_source_best`/`status` 一律由 `_derive` 从 `comparison_results` 算出(协议已按 price 升序、rank=1 最便宜),不信客户端自算。
|
||||
- **C / U(harvest 为主,按 `trace_id` 幂等)**:透传壳 `compare.py` 三段式落库(`app/repositories/comparison.py`)——`harvest_running`(帧0 建 `running` 行)→ `harvest_done`(done 帧转 `success`/`failed` + 派生 `best_*`/`saved_amount_cents`;`newly_success` 仅留日志观测,**不在此发邀请奖**——#113 已把发奖口径移到「实际下单」`POST /order/report`)→ `harvest_abort`(`trace/finalize` 转 `cancelled`/`failed`,**不降级已 success**)。老客户端仍可 `POST /compare/record`(`upsert_record`,按 `trace_id` 查、整行覆盖)兜底。`best_*`/`saved_amount_cents`/`is_source_best`/`status` 一律由 `_derive` 从 `comparison_results` 算出(协议已按 price 升序、rank=1 最便宜),不信客户端自算。
|
||||
- **D**:无(关联的 `price_report` 也只把 `comparison_record_id` 置空,不删本表)。
|
||||
- **R**:`GET /compare/records`(列表,`created_at` 倒序游标 + 「已下单」标记)、`GET /compare/records/{id}`(详情,限本人);`count_success` 给里程碑;`get_stats`(`status='success'` 计数 + `saved_amount_cents` 求和)给 [`GET /compare/stats`](../api/compare-stats.md) 喂「我的」页省钱战绩卡(完成比价 + 累计发现可省,**比价口径**);`report.py` 反查 `best_*`;admin 大盘/明细。
|
||||
- **R**:`GET /compare/records`(列表,`created_at` 倒序游标 + 「已下单」标记)、`GET /compare/records/{id}`(详情,限本人);`count_success` 给里程碑;`get_stats`(`status='success'` 计数 + `saved_amount_cents` 求和)给 [`GET /compare/stats`](../api/compare/compare-stats.md) 喂「我的」页省钱战绩卡(完成比价 + 累计发现可省,**比价口径**);`report.py` 反查 `best_*`;admin 大盘/明细。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
@@ -30,6 +30,7 @@
|
||||
| `saved_amount_cents` | Integer | nullable | 源价 − 最优价(可 0/负:源平台本就最便宜) |
|
||||
| `is_source_best` | Boolean | nullable | 源平台就是最便宜(= 这次没省到) |
|
||||
| `store_name` | String(128) | nullable | 店铺名。**与 `savings_record.shop_name` 按字符串相等关联**,给本记录打「已下单」 |
|
||||
| `product_names` | String(512) | nullable | 菜品/商品名拼接串(`items[].name` 顿号连接,超长截断),**专供 admin 比价记录按店/商品模糊搜索**(#117:对 JSON 列做 LIKE 不可移植,冗余成扁平列;写入时随 harvest/upsert 同步生成)。C 端不读它 |
|
||||
| `total_dish_count` / `skipped_dish_count` | Integer | nullable | 菜品总数 / 目标平台没找到被跳过数 |
|
||||
| `status` | String(16) | NOT NULL, default `success` | 取值:`running`(harvest 帧0 建行、比价进行中)/ `success`(有非源且有价的目标结果)/ `failed`(出错/没采到目标价)/ `cancelled`(用户终止 / Phase1 未识别,`harvest_abort` 写,**不降级已 success**)。**里程碑只数 success** |
|
||||
| `information` | String(256) | nullable | done 帧文案;成功=摘要,失败=具体原因(前端失败时当原因展示) |
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# coupon_session — 领券任务全程流水(admin「领券数据」看板数据源)
|
||||
|
||||
> 模型 `app/models/coupon_state.py`(`CouponSession`) · 仓库 `app/repositories/coupon_state.py`(`upsert_session`) · 接口 C 端 `POST /api/v1/coupon/session`([coupon-session](../api/coupon/coupon-session.md));admin `GET /admin/api/coupon-data/*`(聚合看板 + 明细,`app/admin/routers/coupon_data.py`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
**一次领券任务一行**(`trace_id` 唯一),记从发起(`started`)到收尾(`completed`/`failed`/`abandoned`)的全程:总耗时、各平台耗时、领到张数、机型/ROM、发起来源。与同文件的三张「今日状态」表([coupon_state](./coupon_state.md))分工不同:那三张按「设备×日」管**频控/置灰**,本表按「一次任务」管**漏斗与体验指标**——admin「领券数据」看板的发起数、完成率、中途流失(started 无终态)、平均耗时都从这算。#99 新增(2026-06-30)。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C / U(upsert,按 `trace_id`)**:客户端 `POST /api/v1/coupon/session` **两段上报**——发起时建行(`status='started'`,带 platforms/origin_package/机型),收尾时按同 `trace_id` 更新同一行(终态 + `elapsed_ms` + `platform_elapsed` + `claimed_count`)。发起即落库 → 收尾丢失(App 被杀/断网)的行永远停在 `started` = 中途流失,可量化。
|
||||
- **D**:无。
|
||||
- **R**:admin `GET /admin/api/coupon-data`(按 `started_date` × `app_env` 聚合趋势/漏斗 + 逐条明细,join `user` 出手机号)+ `GET /admin/api/coupon-data/user-records`(用户抽屉:某用户全部领券记录)。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `trace_id` | String(64) | NOT NULL, **UNIQUE**(`uq_coupon_session_trace`) | 一次领券唯一 id(客户端 UUID,全程贯穿),upsert 幂等键;与 pricebot work_logs / `coupon_claim_record.trace_id` 同源 |
|
||||
| `device_id` | String(64) | NOT NULL | 客户端 per-install id |
|
||||
| `user_id` | Integer | index, nullable | 登录态才带(admin join `user` 出手机号/昵称);匿名领券为空。**无硬 FK** |
|
||||
| `status` | String(16) | NOT NULL | `started`(发起)/ `completed` / `failed` / `abandoned`(用户中止)。`started` 无终态 = 中途流失 |
|
||||
| `app_env` | String(16) | index, nullable | `prod` / `dev`(客户端 BuildConfig.DEBUG)。admin 报表默认只看 prod,防测试数据串台 |
|
||||
| `platforms` | JSON(PG: JSONB) | nullable | 发起勾选平台 `["meituan-waimai", ...]`;空=全领 |
|
||||
| `origin_package` | String(64) | nullable | 发起来源外卖 App 包名;null=App 内(傻瓜比价首页)发起,非空=切到美团/淘宝/京东被弹券引导发起。admin「发起平台」列据此区分 |
|
||||
| `device_model` | String(128) | nullable | 机型(Build.MANUFACTURER + MODEL) |
|
||||
| `rom` | String(64) | nullable | ROM(OemDetector,如 `ColorOS 14`) |
|
||||
| `started_at` | DateTime(tz) | NOT NULL | 发起时刻(客户端墙钟) |
|
||||
| `started_date` | Date | NOT NULL | 发起的 Asia/Shanghai 自然日;admin 按天聚合/筛选(复合索引) |
|
||||
| `finished_at` | DateTime(tz) | nullable | 收尾时刻(服务端 now);未收尾(流失)为空 |
|
||||
| `elapsed_ms` | Integer | nullable | 全程耗时(客户端点发起→收尾计时,权威);平均/分位只统计 completed |
|
||||
| `platform_elapsed` | JSON(PG: JSONB) | nullable | 各平台领券耗时 `{"meituan-waimai": 3200, ...}`(ms) |
|
||||
| `claimed_count` | Integer | nullable | 本次领到张数(收尾上报) |
|
||||
| `trace_url` | String(512) | nullable | pricebot 公网调试链接(admin 明细可点开复盘) |
|
||||
| `created_at` / `updated_at` | DateTime(tz) | server_default now() / +onupdate | |
|
||||
|
||||
## 关系 / Join Key
|
||||
- `user_id` 软指 `user.id`(无硬 FK,可空;admin 明细 LEFT JOIN 出手机号)。
|
||||
- `trace_id` 与 pricebot work_logs、[`coupon_claim_record`](./coupon_state.md).`trace_id` 同源(一次任务),无 id 级 join。
|
||||
- `device_id` 与领券三表同源(per-install id)。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`;UNIQUE `trace_id`;index `user_id`、`app_env`;复合 index `ix_coupon_session_date_env`(`started_date`, `app_env`)= admin 看板主查询路径。
|
||||
|
||||
## 注意
|
||||
- 写库 best-effort 口径与领券三表一致:上报失败不影响领券本身(且本表数据源是客户端**独立上报**,不是 `/coupon/step` 透传顺手写)。
|
||||
- `elapsed_ms` 以客户端计时为准(墙钟差不影响);`finished_at - started_at` 只做时刻留痕,不用来算时长。
|
||||
@@ -1,14 +1,16 @@
|
||||
# coupon_state — 领券今日状态三张表(弹窗频控 / 首页置灰 / 领券记录)
|
||||
# coupon_state — 领券状态表(今日状态三张 + 领券流水 coupon_session)
|
||||
|
||||
> 模型 `app/models/coupon_state.py` · 仓库 `app/repositories/coupon_state.py` · 接口 `app/api/v1/coupon.py`(prefix `/api/v1/coupon`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
> 同模型文件里还有第四张表 [`coupon_session`](./coupon_session.md)(一次领券任务一行的全程流水,admin「领券数据」看板数据源,#99)——维度与本文三张「设备×日」状态表不同,单独成文。
|
||||
|
||||
领券(优惠券自动化)联动产生的三张「今日状态」表,都挂在领券透传端点 `POST /api/v1/coupon/step` 这条链路上(pricebot 跑领券,结果回 app-server 落库;**领券过程本身在 pricebot 内存态跑、不落库**)。三表各管一件事:
|
||||
|
||||
- **`coupon_prompt_engagement`** — 弹窗频控源。按 `(device, App 包名, 自然日)` 记「今天**这个 App** 是否对领券引导窗表达过**意向**」(弹出即记 `shown` / 点「一键领取」=`claim_started` / 点拒绝关闭=`dismissed` 都算)。切到外卖 App 时据此决定弹不弹:今天**该 App** engage 过就不再弹该 App。频控维度自 2026-06-14 起含 `package`,美团/淘宝/京东各自独立、互不压制。
|
||||
- **`coupon_daily_completion`** — 首页置灰源。按 `(device, 自然日)` 记「今天是否已**跑完整轮**领券(到 done 帧)」。首页「去领取」卡据此置灰:今天跑完了就不能再领。
|
||||
- **`coupon_claim_record`** — 资产沉淀层。按 `(device, 券, 自然日)` 记每张券的领取结果(success/already_claimed/failed/skipped),**纯沉淀**(资产/画像/排查/CPS 归因),当前**不参与**「要不要领 / 弹不弹」的判断。
|
||||
- **`coupon_session`** — admin「领券数据」看板数据源(**独立流水表,不是「今日状态」表**)。按 `trace_id` 一次领券一行,走 `POST /api/v1/coupon/session` 两段上报(发起/收尾),记全程耗时 + 各平台耗时 + `platform_success`(成功平台,算整单②/点位③成功率)。详见下方专节。
|
||||
|
||||
三表共同口径:
|
||||
前三张「今日状态」表的共同口径:
|
||||
- **判断维度是 `device_id`,不是 `user_id`**:券发到的是设备上登录的那个外卖账号,device 比 user 更贴近「哪个登录环境」,且 `device_id` 全链路现成、不依赖领券鉴权(领券 MVP 阶段 `/coupon/step` 不鉴权)。客户端 `getOrCreateDeviceId` 生成存 SP,**卸载重装会变 → 当新设备重新弹一次**(产品预期)。
|
||||
- **日期 = `Asia/Shanghai` 自然日**(`claim_date` / `engage_date` / `complete_date`,`repositories/coupon_state.today_cn()`)。每日可领的券(签到/天天红包)靠这天然每天一条。
|
||||
- **`user_id` 可空**:领券登录态有就记(资产/画像),可空、**不进唯一键、不阻塞判断**。
|
||||
@@ -90,7 +92,7 @@
|
||||
### 用在哪 / 增删改查
|
||||
- **C / U(幂等 upsert)**:`record_claims`,由 `POST /api/v1/coupon/step` 写入。一帧的券结果来自 pricebot 的 `last_coupon_result`(最后一张)+ `action.params.coupon_results`(全量)——**会重复带同一张券**,端点 `_extract_coupon_results` 先**按 `coupon_id` 去重**(全量覆盖单张),仓库再靠唯一键幂等:已有则更新 `status`/`reason`/`claimed_count`/`extra`(以最后一次为准),否则插入。
|
||||
- **U / D**:无业务删除。
|
||||
- **R**:**当前无读取端点**(纯写入沉淀,未来做去重/归因/画像时再用)。
|
||||
- **R**:`GET /admin/api/coupon-data/coupons`(`coupon_slot_report`)—— admin「按券成功率」表,按 `coupon_id` 聚合 成功/(成功+失败)(`skipped` 排除,设备-天口径,按 `app_env` 过滤)。见设计 §13。
|
||||
|
||||
### 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
@@ -101,6 +103,7 @@
|
||||
| `coupon_id` | String(64) | NOT NULL | 券标识(取自 pricebot 结果) |
|
||||
| `claim_date` | **Date** | NOT NULL | **北京时间**自然日(`today_cn()`);每日可领的券靠它天然每天一条 |
|
||||
| `status` | String(24) | NOT NULL | `success` / `already_claimed` / `failed` / `skipped`(原样取 pricebot coupon 结果) |
|
||||
| `app_env` | String(16) | index, 可空 | 领券所属 session 环境 `prod`/`dev`(`/step` 按 `trace_id` 取 `coupon_session.app_env` 打标);旧行 NULL(不回填)。admin「按券成功率」表按它过滤。见设计 §13 |
|
||||
| `vendor` | String(48) | 可空 | 券提供方 |
|
||||
| `coupon_name` | String(128) | 可空 | 取 pricebot `name` |
|
||||
| `claimed_count` | Integer | 可空 | 这张领到几张(pricebot `display_count`,给不出时 None;兼容 `claimed_count`) |
|
||||
@@ -120,7 +123,49 @@
|
||||
|
||||
---
|
||||
|
||||
## 三表共性小结
|
||||
## coupon_session — 领券任务流水(一次领券一行,admin「领券数据」看板数据源)
|
||||
|
||||
`trace_id` 唯一,一次领券一行。与上面三张「今日状态」表不同:本表走 `POST /api/v1/coupon/session`(客户端**两段上报**:发起 `started` 建行、收尾 `completed`/`failed`/`abandoned` 按 `trace_id` 更新同一行),记从发起到收尾的全程耗时 + 各平台耗时 + 机型/ROM。发起即落库 → admin 可算「发起数」与中途流失(started 无终态 = 未完成)。
|
||||
|
||||
### 用在哪 / 增删改查
|
||||
- **C / U(幂等 upsert)**:`upsert_coupon_session`,由 `POST /api/v1/coupon/session` 两段上报。**状态只前进**(started 帧重复到不覆盖已有终态);终态补 `finished_at`。
|
||||
- **U(并集写)**:`merge_session_platform_success`,由 `POST /api/v1/coupon/step` 每逢**带券结果的帧**调用——把本帧「成功平台」(`status∈{success,already_claimed}` 的券 → `coupon_id` 前缀映射平台)**并入** `platform_success`(并集幂等,无新平台不写;读不到该 trace 行则跳过)。复用 `record_claims` 的同一 `SessionLocal`,不新增连接。
|
||||
- **R**:admin `GET /admin/api/coupon-data`(`coupon_data_report`)—— 发起/完成数、耗时分位、**整单成功率②/点位成功率③**、按天/小时趋势、逐条明细;`GET /admin/api/coupon-data/user-records` 某用户全部领券。
|
||||
|
||||
### 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `trace_id` | String(64) | NOT NULL, UNIQUE | 一次领券唯一 id(客户端 UUID,全程贯穿),upsert 键 |
|
||||
| `device_id` | String(64) | NOT NULL | |
|
||||
| `user_id` | Integer | index, 可空 | 登录态才带(admin join 用户表出手机号/昵称);匿名领券为空 |
|
||||
| `status` | String(16) | NOT NULL | `started` / `completed` / `failed` / `abandoned`;started 无终态 = 中途流失 |
|
||||
| `app_env` | String(16) | index, 可空 | `prod` / `dev`;admin 报表默认只看 prod(防测试串台) |
|
||||
| `platforms` | JSON | 可空 | 发起勾选平台 `["meituan-waimai",…]`(空 = 全领三档);**③点位成功率的分母来源** |
|
||||
| `origin_package` | String(64) | 可空 | 发起来源外卖 App 包名;null = App 内(傻瓜比价首页)发起,非空 = 外卖侧弹券 |
|
||||
| `device_model` | String(128) | 可空 | Build.MANUFACTURER + MODEL |
|
||||
| `rom` | String(64) | 可空 | OemDetector,如 "ColorOS 14" |
|
||||
| `started_at` | DateTime(tz) | NOT NULL | 发起时刻(客户端墙钟);明细「时间」列、趋势 X 轴 |
|
||||
| `started_date` | **Date** | NOT NULL | 发起的**北京**自然日;admin 按天聚合/筛选(索引) |
|
||||
| `finished_at` | DateTime(tz) | 可空 | 收尾时刻(服务端 now);未收尾(流失)为空 |
|
||||
| `elapsed_ms` | Integer | 可空 | 全程耗时(ms,客户端点发起→收尾);均值/分位只统计 completed |
|
||||
| `platform_elapsed` | JSON | 可空 | 各平台领券耗时 `{"meituan-waimai":3200,…}`(ms) |
|
||||
| `claimed_count` | Integer | 可空 | 领到总张数(收尾帧带) |
|
||||
| `platform_success` | JSON(PG JSONB) | 可空 | **本次至少领到一张(`status∈{success,already_claimed}`)的平台 id 列表**,如 `["meituan-waimai","jd-waimai"]`。`/step` 逐帧按 `trace_id` **并集**写入(`merge_session_platform_success`);旧行 NULL 视作空集。admin 据此算整单成功率②(`platforms`⊆`platform_success`)/点位成功率③(Σ交集/Σ勾选)。设计:[领券成功率指标](../guides/领券成功率指标-设计与埋点.md) |
|
||||
| `trace_url` | String(512) | 可空 | pricebot done 帧回传的公网 trace 链接;未到 done(failed/abandoned)为空 |
|
||||
| `created_at` | DateTime(tz) | server_default now() | |
|
||||
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | |
|
||||
|
||||
### 索引与约束
|
||||
- PK `id`;index `user_id`、`app_env`;UNIQUE(`trace_id`) = `uq_coupon_session_trace`;Index(`started_date`, `app_env`) = `ix_coupon_session_date_env`(admin 主聚合/筛选)。
|
||||
|
||||
### 注意
|
||||
- `platform_success` 是**布尔性质**的平台集,跨帧**并集**天然幂等 → `/step` 每帧并入不重复计;失败/中途退出的 session 也能拿到崩溃前已成的平台。写放大 ≈ 领券券数(仅带券结果的帧写)。
|
||||
- 成功率**基数 = 区间全部 session**(含 abandoned/failed),与「发起数」同基数(设计 §3)。`coupon_id → 平台` 用前缀(`mt_`/`tb_`·`ele_`·`elm_`/`jd_`),与客户端 `couponIdToPlatform` 同词表。
|
||||
|
||||
---
|
||||
|
||||
## 今日状态三表共性小结
|
||||
- 数据流向:客户端 → `POST /api/v1/coupon/step`(透传给 pricebot)→ 结果回写这三张表(best-effort,写库失败不影响领券)。
|
||||
- 唯一键都含 `device_id` + 某个北京自然日列(engagement 还含 `package`,按 App 频控);`user_id` 永远是可空旁路(资产留痕,不进唯一键、不阻塞判断)。
|
||||
- 无硬外键:`user_id` 软指 `user.id`、`trace_id` 软指 pricebot work_logs(详见 [OVERVIEW → 表间关系 & Join Key](./OVERVIEW.md))。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 模型 `app/models/cps_order.py` · 仓库 `app/admin/repositories/cps.py`(`reconcile_orders` / `_map_order_fields` / `list_orders`、统计 `group_stats`) · 接口 admin `POST /admin/api/cps/orders/reconcile`、`GET /admin/api/cps/orders`、`GET /admin/api/cps/stats` · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
从美团联盟 `query_order` 按时间窗拉回、按 `sid` 归群的订单明细,是 CPS 群发漏斗的**最下游"赚了多少佣金"**:用户点 [`cps_link`](./cps_link.md)(携群 [`cps_group`](./cps_group.md) 的 `sid`)下单后,美团把订单连同 `sid` 回传,这里按 `sid` 归群做对账,与 [`cps_click`](./cps_click.md) 的点击量在 `group_stats` 汇成"点击→下单→佣金"漏斗。**仅美团有此表**——淘宝/京东无对账 API,统计里对账字段显示 `-`。
|
||||
从联盟 API 按时间窗拉回、按 `sid` 归群的订单明细,是 CPS 群发漏斗的**最下游"赚了多少佣金"**:用户点 [`cps_link`](./cps_link.md)(携群 [`cps_group`](./cps_group.md) 的 `sid`)下单后,联盟把订单连同 `sid` 回传,这里按 `sid` 归群做对账,与 [`cps_click`](./cps_click.md) 的点击量在 `group_stats` 汇成"点击→下单→佣金"漏斗。**平台覆盖**:初版仅美团(`query_order`);#90(2026-06-30)接入**京东联盟**订单(拉单进同一张表,`platform='jd'`,配 `jd_*`/`external_*` 列,喂数据大盘);淘宝仍无对账 API,统计里对账字段显示 `-`。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C/U(upsert)**:`POST /admin/api/cps/orders/reconcile`(`reconcile_orders`,需 `finance` 角色)。调 `meituan.query_order(sid?, start_time, end_time, page, limit=100)` 分页拉单(`max_pages=200` 防死循环),每条经 `_map_order_fields` 转字段,按 `order_id` **upsert**:不存在则 insert,存在则逐字段覆盖(订单状态会随时间变 付款→完成→结算/退款,重复拉则更新)。返回 `{fetched, inserted, updated, pages}`。`order_id` 全局唯一即幂等键。
|
||||
@@ -15,7 +15,10 @@
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join / 源字段) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `order_id` | String(64) | **UNIQUE, index, NOT NULL** | 美团订单号(加密串),`orderId`。upsert 幂等键 |
|
||||
| `platform` | String(20) | index, NOT NULL, default `meituan` | 订单来源联盟:`meituan` / `jd`(#90)。统计/大盘按它分平台 |
|
||||
| `order_id` | String(64) | **UNIQUE, index, NOT NULL** | 订单号(美团加密串 `orderId`;京东为联盟订单号)。upsert 幂等键 |
|
||||
| `external_order_id` | String(128) | index, nullable | 平台原始订单号(#90,京东 `orderId`;美团 null) |
|
||||
| `external_row_id` | String(128) | index, nullable | 平台订单行号(#90,京东一单多 sku 时区分行;美团 null) |
|
||||
| `sid` | String(64) | index, **nullable** | 渠道追踪位 = 群 `sid`,源 `sid`。**按它归群聚合**;历史无 sid 订单为空 |
|
||||
| `act_id` | String(64) | index, nullable | 活动物料 ID,源 `actId`(转字符串) |
|
||||
| `biz_line` | Integer | nullable | 业务线,源 `businessLine`。`1=外卖` |
|
||||
@@ -25,9 +28,14 @@
|
||||
| `commission_rate` | String(16) | nullable | 佣金率,源 `commissionRate`。`"300"=3%`、`"10"=0.1%`(原样字符串,前端解释) |
|
||||
| `refund_price_cents` | Integer | nullable | 退款金额(分),源 `refundPrice`(元) |
|
||||
| `refund_profit_cents` | Integer | nullable | 退款佣金(分),源 `refundProfit`(元) |
|
||||
| `mt_status` | String(8) | index, nullable | 美团订单状态,源 `status`:`2`付款 `3`完成 `4`取消 `5`风控 `6`结算 |
|
||||
| `estimated_commission_cents` | Integer | nullable | 预估佣金(分,#90 京东口径;美团用 `commission_cents`) |
|
||||
| `actual_commission_cents` | Integer | nullable | 实际/结算佣金(分,#90 京东口径) |
|
||||
| `mt_status` | String(8) | index, nullable | 美团订单状态,源 `status`:`2`付款 `3`完成 `4`取消 `5`风控 `6`结算;京东订单为 null |
|
||||
| `jd_valid_code` | String(16) | index, nullable | 京东订单有效码(#90,联盟 `validCode`,判有效/无效/风控);美团订单为 null |
|
||||
| `invalid_reason` | String(128) | nullable | 失效原因,源 `invalidReason` |
|
||||
| `product_name` | String(512) | nullable | 商品名,源 `productName`(超 500 截断) |
|
||||
| `settle_month` | String(16) | nullable | 结算月份(#90 京东) |
|
||||
| `site_id` / `position_id` / `pid` | String(128) | nullable | 京东推广位维度(#90):站点/推广位/联盟 pid,归因用 |
|
||||
| `pay_time` | DateTime(tz) | index, nullable | 付款时间,源 `payTime`(秒级 ts)。统计按时间窗过滤的就是它 |
|
||||
| `mt_update_time` | DateTime(tz) | nullable | 美团侧更新时间,源 `updateTime`(秒级 ts) |
|
||||
| `raw` | JSON / JSONB | NOT NULL, default `{}` | `query_order` 单条原始 dataList,留底排查/补字段 |
|
||||
@@ -48,4 +56,5 @@
|
||||
- **"未归群"独立行**:`group_stats` 遍历完所有群后,对剩下的、有订单但 `sid` 不属任何现存群的 sid(历史遗留如 `wonderableai`、或别处来源、或群被删),单列一行 `group_id=None`、有对账无点击。所以本表 `sid` 可空/可孤立是设计内的,不是脏数据。
|
||||
- **金额/时间统一转换的原因**:`query_order` 返回金额是「元」字符串、时间是秒级 ts;`_yuan_to_cents`(Decimal 防浮点,`"null"`/空 → None)与 `_ts_to_dt`(转 tz-aware UTC,前端按北京展示)在入库时归一,与全站"金额存分、时间存 tz-aware"口径对齐。`raw` 整条留底,字段不够时不用重拉。
|
||||
- **upsert 而非 append**:同一订单会被多次拉到(状态变化),按 `order_id` 覆盖即可拿到最新状态;`reconcile` 可重复跑(幂等)。
|
||||
- **本表自始至终只服务美团**:初版(`277f9b1`)就定型,`3a40f61` 接淘宝/京东时**没动本表**——那俩平台无对账 API,统计里对账列直接给 `None`(前端显示 `-`)。这是"对账 = 美团专属"的边界。
|
||||
- **平台边界的演变**:初版(`277f9b1`)只服务美团;#90(2026-06-30)接入**京东联盟**拉单进同一张表(`platform='jd'` + `external_*`/`jd_valid_code`/`estimated|actual_commission_cents` 等列,有效性按 `jd_valid_code` 判,喂 admin 数据大盘的京东收益)。**淘宝仍无对账 API**,统计对账列给 `None`(前端显示 `-`)。
|
||||
- **#119 修 `pay_time` 缺失**:早期美团拉单部分订单 `payTime` 空导致 `pay_time` 为 null → 大盘按时间窗过滤漏算美团收益;#119 起入库补齐/回填,时间窗统计以 `pay_time` 为准。
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
## 用在哪 / 增删改查
|
||||
- **C / U(upsert)**:`register_or_update`(`POST /device/register`,App 拿到 push token 时调)按 `(user_id, device_id)` upsert,只在传入非空时更新 `registration_id`/`platform`/`app_version`;`touch_heartbeat`(`POST /device/heartbeat`,无障碍服务存活时周期调,**心跳也能自注册**)在 `accessibility_enabled=true` 时刷 `last_heartbeat_at`、置 `ever_protected=true`、状态机重置回 `alive`、清 `notified_at`(掉线恢复→下次再断才再推一条)。
|
||||
- **U(worker)**:`mark_notified`(`heartbeat_monitor_worker` 检出掉线后)置 `liveness_state='notified'` + `notified_at` + **`kill_alert_pending=True`**;`ack_kill_alert`(`POST /device/liveness/ack`,客户端弹过引导后)清 `kill_alert_pending`(幂等)。
|
||||
- **R**:`list_overdue`(worker 扫描:`ever_protected=true` + `liveness_state='alive'` + `last_heartbeat_at` 早于 `now - timeout`)→ 掉线设备列表;`get_device`(`GET /device/liveness`,客户端进 App 拉本机是否被判掉线过)。
|
||||
- **R**:`list_overdue`(worker 扫描:`ever_protected=true` + `liveness_state='alive'` + `last_heartbeat_at` 早于 `now - timeout`)→ 掉线设备列表;`get_device`(`GET /device/liveness`,客户端进 App 拉本机是否被判掉线过);admin `GET /admin/api/device-liveness/stats` + 列表(#80,后台「设备存活监控」页:总数/在线/掉线卡片 + 明细)。
|
||||
- **D**:无。
|
||||
|
||||
## 字段
|
||||
@@ -22,6 +22,7 @@
|
||||
| `platform` | String(16) | NOT NULL, default `android` | |
|
||||
| `app_version` | String(32) | nullable | 上报时 App 版本 |
|
||||
| `ever_protected` | Boolean | NOT NULL, default false | 收到过 service 心跳即 true(=该设备开过无障碍,功能对它有意义)。`list_overdue` 的过滤前提 |
|
||||
| `first_protected_at` | DateTime(tz) | nullable | **首次**开启无障碍(首次收到心跳)的时刻(#80);置 `ever_protected=true` 时一并写、之后不再变。admin 设备存活监控用它算「开启保护耗时/转化」 |
|
||||
| `last_heartbeat_at` | DateTime(tz) | index, nullable | 最近一次 service 心跳时间(存活证明);超时即视为保护掉线 |
|
||||
| `last_report_protection_on` | Boolean | NOT NULL, default false | 最近一次上报的无障碍开关状态(观测用) |
|
||||
| `liveness_state` | String(16) | NOT NULL, default `unknown` | 状态机:`unknown` → `alive`(收到 service 心跳)→ `silent`/`notified`(扫描发现超时并已推送);心跳恢复 handler 重置回 `alive` |
|
||||
@@ -40,4 +41,4 @@
|
||||
## 注意
|
||||
- **`kill_alert_pending` 为什么和 `liveness_state` 解耦**:服务随 App 重启会先发心跳把 `state` 重置回 `alive`,若复用 `state` 判「待提醒」,客户端进 App 这一刻可能恰好已被重置 → 漏看这次掉线。故另设一个只由 worker 置、只由客户端 ack 清的标记,规避竞态。
|
||||
- **`list_overdue` 本期不要求有 `registration_id`**:本期只做终端打印检测、未真推送,没接极光 token 的设备也要检出。
|
||||
- 心跳超时阈值由 worker 的 `timeout_minutes` 决定(不在表里)。
|
||||
- 心跳超时阈值由 worker 的 `timeout_minutes` 决定(不在表里);#107 起默认 **1 小时**(原 10 分钟误报率高:息屏/省电模式下心跳会正常停发)。
|
||||
|
||||
+25
-11
@@ -1,14 +1,14 @@
|
||||
# feedback — 用户帮助与反馈
|
||||
# feedback — 用户帮助与反馈(含审核发奖)
|
||||
|
||||
> 模型 `app/models/feedback.py` · 仓库 `app/repositories/feedback.py` · 接口 [feedback](../api/feedback.md) · admin [admin-feedbacks-list](../api/admin-feedbacks-list.md) / [admin-feedback-handle](../api/admin-feedback-handle.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
> 模型 `app/models/feedback.py` · 仓库 `app/repositories/feedback.py` · 接口 C 端 [feedback](../api/other/feedback.md) / [feedback-records](../api/other/feedback-records.md);admin `GET /admin/api/feedbacks`、`/summary`、`POST /{id}/approve|reject|handle`(`app/admin/routers/feedback.py`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
App「帮助与反馈」每次提交写一行。`content` 必填;`contact` 原必填,**原型改版后客户端不再采集,新数据存空串**(列保持 NOT NULL、免迁移,历史数据仍有值);`images` 为可选截图(≤6 张)。后台人工处理后置 `handled`。与 `price_report`(结构化上报更低价)不同,本表是**自由文本**反馈。
|
||||
App「帮助与反馈」每次提交写一行。2026-06 起演进为**轻审核工单**:运营在后台采纳(`adopted`,可发金币)/拒绝(`rejected`,填原因)并可写**运营回复**(#105),C 端「我的反馈」列表把状态与回复展示给用户;提交侧自动采集**来源/场景 + 端环境**(App 版本/机型/ROM/Android 版本,#94)供排障。与 `price_report`(结构化上报更低价)不同,本表是**自由文本**反馈。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C(插入)**:`POST /api/v1/feedback`(multipart:`content` + 可选 `contact` + 可选 `images`;`create_feedback`)。截图先经 `core.media` 落 `/media/feedback/` 拿相对路径,再随反馈写入,`status='new'`。
|
||||
- **U(更新)**:admin 处理反馈 `update_feedback_status` → `status='handled'`(同事务写 `admin_audit_log`)。
|
||||
- **C(插入)**:`POST /api/v1/feedback`(multipart:`content` + 可选 `contact`/`images`/`source`/`scene`;客户端自动带 `app_version`/`device_model`/`rom_name`/`android_version`)→ `status='pending'`。截图经 `core.media` 落 `/media/feedback/`。
|
||||
- **U(更新,admin,均写 `admin_audit_log`)**:`approve`(→`adopted`,可选发金币 `reward_coins`,走 `grant_coins` 同事务)/ `reject`(→`rejected`,`reject_reason`)/ `handle`(→`handled`,旧口径「标记已处理」保留)。三者均可写 `admin_reply`(用户可见回复)与 `review_note`(内部备注)。
|
||||
- **D**:无。
|
||||
- **R**:admin 反馈列表(可按 `status` 筛)。C 端当前无"我的反馈列表"读接口。
|
||||
- **R**:C 端 `GET /api/v1/feedback/records`(我的反馈历史,展示状态/回复/奖励);admin 列表(状态/来源筛选)+ `summary`(各状态计数)。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
@@ -16,17 +16,31 @@ App「帮助与反馈」每次提交写一行。`content` 必填;`contact` 原
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `user_id` | Integer | FK→user.id, index, NOT NULL | 提交用户 |
|
||||
| `content` | Text | NOT NULL | 反馈正文 |
|
||||
| `contact` | String(128) | NOT NULL | 联系方式(微信/QQ/手机)。客户端改版后不再采集,新数据为空串;列仍 NOT NULL |
|
||||
| `images` | JSON | nullable | 截图相对 URL 列表 `/media/feedback/...`;无图为 NULL |
|
||||
| `status` | String(16) | NOT NULL, default `new` | 取值:`new`(待处理)/ `handled`(已处理) |
|
||||
| `contact` | String(128) | NOT NULL | 联系方式。客户端改版后不再采集,新数据为空串;列仍 NOT NULL |
|
||||
| `source` | String(16) | NOT NULL, default `profile`, index | 提交入口来源(#105):`profile`(设置/我的页普通反馈)/ 比价场景值;客户端显式传优先,未传由 `scene` 有无派生 |
|
||||
| `scene` | String(32) | nullable | 比价反馈的「问题场景」(找错商品/优惠不对/比价太慢…,#105);比价结果页反馈才有,普通反馈为 NULL |
|
||||
| `images` | JSON | nullable | 截图相对 URL 列表 `/media/feedback/...` |
|
||||
| `app_version` | String(32) | nullable | 提交时 App versionName(#94) |
|
||||
| `device_model` | String(64) | nullable | 机型 Build.MODEL(#94) |
|
||||
| `rom_name` | String(32) | nullable | ROM(OemDetector:ColorOS/MIUI/…,#94) |
|
||||
| `android_version` | String(16) | nullable | Android 版本(#94) |
|
||||
| `status` | String(16) | NOT NULL, default `pending`, index | `pending`(待处理)/ `adopted`(已采纳,#94)/ `rejected`(已拒绝)/ `handled`(旧「已处理」口径,兼容保留;历史另有 `new`) |
|
||||
| `reject_reason` | String(256) | nullable | 拒绝原因(用户可见) |
|
||||
| `reward_coins` | Integer | nullable | 采纳发的金币数(未发为 null;发币走 `coin_transaction`) |
|
||||
| `review_note` | String(256) | nullable | 运营内部备注(不外露) |
|
||||
| `admin_reply` | String(256) | nullable | **运营回复**(用户可见,#105;C 端反馈历史展示) |
|
||||
| `reviewed_by_admin_id` | Integer | nullable | 审核管理员 id(软指 `admin_user.id`) |
|
||||
| `reviewed_at` | DateTime(tz) | nullable | 审核时间 |
|
||||
| `created_at` | DateTime(tz) | server_default now(), index | 提交时间 |
|
||||
|
||||
## 关系 / Join Key
|
||||
- `user_id` → `user.id`(多对一)。
|
||||
- admin 处理时被 `admin_audit_log` 记录(`target_type='feedback'`、`target_id`=本行 id)。
|
||||
- admin 审核动作被 `admin_audit_log` 记录(`target_type='feedback'`);`reviewed_by_admin_id` 软指 `admin_user.id`(无硬 FK)。
|
||||
- 采纳发奖时写 `coin_transaction`(发币入口 `grant_coins` 同事务)。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`;index `user_id`、`created_at`。
|
||||
- PK `id`;index `user_id`、`status`、`created_at`。
|
||||
|
||||
## 注意
|
||||
- `images` 用通用 `JSON`(本表**未**用 JSONB variant,与 comparison/savings 不同)。
|
||||
- 状态机是单向的:`pending → adopted/rejected/handled`。`approve`/`reject` 只接受 `pending`(或历史 `new`)态,重复审核报 400;旧口径 `handle` **幂等不校验原状态**(重复调用结果一致)。
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# invite_cash_transaction — 邀请奖励金流水账本(分)
|
||||
|
||||
> 模型 `app/models/wallet.py`(`InviteCashTransaction`) · 仓库 `app/repositories/wallet.py`(`grant_invite_cash` / 提现路径按 `source` 分账) · 接口 [wallet-account](../api/wallet/wallet-account.md)(余额)/ [wallet-withdraw](../api/wallet/wallet-withdraw.md)(`source=invite_cash` 提现) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
**邀请奖励金**(现金,分)的流水账本,与金币兑换的 `cash_transaction` **物理隔离**、结构同构:`balance_after_cents` 记的是 `coin_account.invite_cash_balance_cents`。隔离原因:邀请奖励金有独立的发放口径(好友**比价并下单**才发,#113)与独立的提现对账(`withdraw_order.source='invite_cash'`,#121),混在现金账本里无法分账校验。#82 新增(2026-06-27)。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C(插入)**:每次奖励金变动一笔:
|
||||
|
||||
| 动作 / endpoint | `biz_type` | `amount_cents` | `ref_id` 指向 |
|
||||
|---|---|---|---|
|
||||
| 好友比价并下单发奖(`POST /order/report` → `invite.try_reward_on_compare` → `grant_invite_cash`) | `invite_reward` | + | 被邀请人 `user.id`(字符串) |
|
||||
| 发起提现 `POST /wallet/withdraw`(`source=invite_cash`) | `invite_withdraw` | − | `withdraw_order.out_bill_no` |
|
||||
| 该提现失败/审核拒绝退款 | `invite_withdraw_refund` | + | `withdraw_order.out_bill_no` |
|
||||
| admin 手动调整 `POST /admin/api/users/{id}/cash`(`account=invite_cash`,#95) | `admin_grant` / `admin_deduct` | + / − | null(原因记 `remark`=`admin:<reason>`) |
|
||||
|
||||
- **U / D**:无。账本只追加。
|
||||
- **R**:admin 用户 360 / 提现资金账本校验 `GET /admin/api/withdraws/ledger-check`(按 `source` 分账对账,#121);C 端邀请页战绩(`invite.get_reward_stats`:余额 + 累计已提)。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户(= 邀请人) |
|
||||
| `amount_cents` | Integer | NOT NULL | 本笔变动(分);正=入账(发奖/退款),负=出账(提现) |
|
||||
| `balance_after_cents` | Integer | NOT NULL | 本笔后奖励金余额(= 当时 `coin_account.invite_cash_balance_cents`) |
|
||||
| `biz_type` | String(32) | NOT NULL | `invite_reward` / `invite_withdraw` / `invite_withdraw_refund` |
|
||||
| `ref_id` | String(64) | nullable | 见上表:发奖=被邀请人 id;提现/退款=`withdraw_order.out_bill_no` |
|
||||
| `remark` | String(128) | nullable | 用户可见备注(如「好友比价奖励」「提现到微信零钱(待审核)」) |
|
||||
| `created_at` | DateTime(tz) | server_default now(), index | |
|
||||
|
||||
## 关系 / Join Key
|
||||
- `user_id` → `user.id`(多对一,硬 FK)。
|
||||
- `ref_id` →(withdraw 类)`withdraw_order.out_bill_no`(软关联;该单 `source='invite_cash'`)/(invite_reward)被邀请人 `user.id`。
|
||||
- 与 `invite_relation` 的联动:发奖同事务置 `invite_relation.compare_reward_granted=true`(幂等闸,一个被邀请人只发一次)。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`;index `user_id`、`created_at`。
|
||||
|
||||
## 注意
|
||||
- **唯一变动入口 `wallet.grant_invite_cash`**:更新 `coin_account.invite_cash_balance_cents` + 写本表,**不 commit**,调用方同事务提交(与 `grant_coins` 同款约定)。
|
||||
- 提现按 `source` 走不同账本:`coin_cash` → `cash_transaction`,`invite_cash` → 本表;`withdraw_order` 两种共用一张表靠 `source` 列区分。
|
||||
@@ -2,15 +2,16 @@
|
||||
|
||||
> 模型 `app/models/invite.py`(`InviteRelation`) · 仓库 `app/repositories/invite.py` · 接口 `app/api/v1/invite.py`:`POST /api/v1/invite/bind`(写)、`GET /api/v1/invite/me`(战绩)、`GET /api/v1/invite/invitees`(列表) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
一行 = 一次**成功的**邀请绑定。被邀请人(B)用邀请人(A)的邀请码完成绑定后写入,**注册即生效**:同一事务里给 A、B 各发 1 万金币(= 1 元,可提现)。数据来自 `bind()`,触发它的归因来源有三种(`channel`)。这是邀请功能的**结果表 / 账本**;邀请码本身存在 `user.invite_code`,不在这张表。
|
||||
一行 = 一次**成功的**邀请绑定。被邀请人(B)用邀请人(A)的邀请码完成绑定后写入,**绑定关系注册即生效,但绑定本身不发奖**(#113 起):发奖后置到 B **完成比价并实际下单**(`POST /order/report` → `try_reward_on_compare`),给 A 发**邀请奖励金**(入独立账本 [invite_cash_transaction](./invite_cash_transaction.md),非金币),本表 `compare_reward_granted` 做幂等闸。数据来自 `bind()`,触发它的归因来源有三种(`channel`)。这是邀请功能的**结果表 / 账本**;邀请码本身存在 `user.invite_code`,不在这张表。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C(插入)**:`POST /api/v1/invite/bind`(`bind_invite`)→ `invite_repo.bind()`。过四道防线后建一行 `status='effective'`,**同事务**复用 `wallet.grant_coins` 给双方发金币(`grant_coins` 只 flush,由 `bind()` 统一 commit)→ "建关系 + 双方加金币"原子。
|
||||
- **幂等键 = `invitee_user_id` 唯一**:一个 B 只能被绑一次,重复请求返回 `already_bound`,不重复发奖(仿 `ad_reward_record.trans_id` 思路)。并发下两请求同时插同一 invitee → 后者撞唯一约束 `IntegrityError`,`bind()` rollback 后改判 `already_bound` 兜底。
|
||||
- 四道防线(`bind()` 内):① invitee 已绑 → `already_bound`;② 无效码 / 邀请人非 `active` → `invalid_code`;③ 自邀(inviter==invitee)→ `self_invite`;④ 新人闸 `_is_new_user`(B 的 `created_at` 在 `rewards.INVITE_NEW_USER_WINDOW_HOURS`=72h 内)→ 否则 `not_eligible`。只有全过才插行 + 发奖。
|
||||
- **U / D**:无。这张表只增不改不删(纯账本)。
|
||||
- **C(插入)**:`POST /api/v1/invite/bind`(`bind_invite`)→ `invite_repo.bind()`。过四道防线后建一行 `status='effective'`。**#113 起绑定只建关系、不发奖**(`inviter_coin`/`invitee_coin` 新行恒 0)。
|
||||
- **幂等键 = `invitee_user_id` 唯一**:一个 B 只能被绑一次,重复请求返回 `already_bound`(仿 `ad_reward_record.trans_id` 思路)。并发下两请求同时插同一 invitee → 后者撞唯一约束 `IntegrityError`,`bind()` rollback 后改判 `already_bound` 兜底。
|
||||
- 四道防线(`bind()` 内):① invitee 已绑 → `already_bound`;② 无效码 / 邀请人非 `active` → `invalid_code`;③ 自邀(inviter==invitee)→ `self_invite`;④ 新人闸 `_is_new_user`(B 的 `created_at` 在 `rewards.INVITE_NEW_USER_WINDOW_HOURS`=72h 内)→ 否则 `not_eligible`。
|
||||
- **U(发奖,#113)**:B 比价后下单 `POST /order/report` → `try_reward_on_compare(invitee_user_id)`:`compare_reward_granted=False` 才发 → 置 `True` + 记 `compare_reward_cents`/`compare_rewarded_at`,**同事务** `wallet.grant_invite_cash` 给 A 入邀请奖励金 → "置闸 + 入账"原子,一个 B 只发一次。
|
||||
- **D**:无。
|
||||
- **R**:
|
||||
- `GET /api/v1/invite/me`(`my_invite`)→ `get_stats(inviter_id)`:`count(*)` 得已邀人数、`sum(inviter_coin)` 得累计金币。
|
||||
- `GET /api/v1/invite/me`(`my_invite`)→ `get_stats(inviter_id)`:`count(*)` 得已邀人数、`sum(inviter_coin)` 得累计金币(历史口径);另调 `get_reward_stats` 出邀请奖励金余额/累计已提(读 `coin_account.invite_cash_balance_cents` + `withdraw_order(source=invite_cash)`)。
|
||||
- `GET /api/v1/invite/invitees`(`my_invitees`)→ `get_invitees(inviter_id, limit, offset)`:join `user` 出被邀请人列表(倒序分页),名字降级兜底 `nickname → wechat_nickname → 脱敏手机号`,`coins` 取 `inviter_coin`。
|
||||
|
||||
## 字段
|
||||
@@ -20,15 +21,18 @@
|
||||
| `inviter_user_id` | Integer | FK→user.id, index, NOT NULL | 邀请人(A) |
|
||||
| `invitee_user_id` | Integer | FK→user.id, **UNIQUE**, index, NOT NULL | 被邀请人(B)。**唯一 = 幂等键**,一个 B 只能被归因一次 |
|
||||
| `channel` | String(16) | NOT NULL, default `clipboard` | 归因来源:`clipboard`(剪贴板自动)/ `manual`(手动填码)/ `fingerprint`(指纹反查兜底)。入库前 `[:16]` 截断 |
|
||||
| `status` | String(16) | NOT NULL, default `effective` | 当前只有 `effective`(注册即生效)。**预留** `pending`/`effective`:将来若改"完成首单才生效"时启用 |
|
||||
| `inviter_coin` | Integer | NOT NULL, default 0 | 本次给 A 发的金币(记账留痕,=`rewards.INVITE_INVITER_COINS`=10000) |
|
||||
| `invitee_coin` | Integer | NOT NULL, default 0 | 本次给 B 发的金币(=`rewards.INVITE_INVITEE_COINS`=10000) |
|
||||
| `status` | String(16) | NOT NULL, default `effective` | 当前只有 `effective`(绑定关系注册即生效)。「发奖后置」没有走 status,而是用下面的 `compare_reward_granted` 闸表达 |
|
||||
| `inviter_coin` | Integer | NOT NULL, default 0 | **历史留痕**(#113 前"绑定即发金币"时代给 A 发的金币,当时=10000);#113 起新绑定恒 0 |
|
||||
| `invitee_coin` | Integer | NOT NULL, default 0 | 同上,给 B 发的金币历史留痕;新绑定恒 0 |
|
||||
| `compare_reward_granted` | Boolean | NOT NULL, default false | **发奖幂等闸**(#113):B 首次「比价并下单」后置 true,一个 B 只给 A 发一次邀请奖励金 |
|
||||
| `compare_reward_cents` | Integer | NOT NULL, default 0 | 实发的邀请奖励金(分,留痕;改常量不影响历史行) |
|
||||
| `compare_rewarded_at` | DateTime(tz) | nullable | 发奖时间 |
|
||||
| `created_at` | DateTime(tz) | server_default now(), index | 绑定时间(= 列表倒序键) |
|
||||
|
||||
## 关系 / Join Key
|
||||
- `inviter_user_id` → `user.id`(硬 FK,多对一):一个 A 可邀多个 B。
|
||||
- `invitee_user_id` → `user.id`(硬 FK,**一对一**,唯一约束):一个 B 至多一行。
|
||||
- 发金币时落 `coin_transaction`:`biz_type='invite_inviter'`(给 A,`ref_id=invitee.id`)/ `biz_type='invite_invitee'`(给 B,`ref_id=inviter.id`),双方 `ref_id` 互指对方便于对账。
|
||||
- 流水关联:**#113 前**的绑定发金币落 `coin_transaction`(`biz_type='invite_inviter'`/`'invite_invitee'`,`ref_id` 互指对方)——现为历史类型,新绑定不再产生;**#113 起**发奖落 [`invite_cash_transaction`](./invite_cash_transaction.md)(`biz_type='invite_reward'`,`ref_id=被邀请人 id`)。
|
||||
- `get_invitees` 用 `InviteRelation JOIN user ON user.id = invitee_user_id` 取被邀请人资料;`total` 单独 `count` 算 `has_more`。
|
||||
|
||||
## 索引与约束
|
||||
@@ -39,8 +43,8 @@
|
||||
|
||||
## 注意
|
||||
- **防重复发奖三道**(`repositories/invite.py` docstring):① `invitee_user_id` 唯一(应用层 `_relation_of_invitee` 先查 + DB 唯一约束并发兜底);② 自邀屏蔽;③ 手机号天然唯一(每个 B = 一个真实手机号账号)= 限制刷量规模。
|
||||
- **`status` 当前恒为 `effective`**:产品取"注册即生效"而非"完成首单才生效",`pending` 取值是为后者预留、目前不写入。
|
||||
- **`inviter_coin`/`invitee_coin` 是留痕字段**:写死当时发的金币值,即便日后改奖励常量,历史行仍保留发奖时的额度,便于对账。
|
||||
- **`status` 当前恒为 `effective`**(绑定关系维度);「发奖后置」由 `compare_reward_granted` 闸表达,没有引入 `pending` 状态。
|
||||
- **金币留痕字段已冻结**:`inviter_coin`/`invitee_coin` 只反映 #113 前旧口径的历史发放额,便于对账;新奖励额看 `compare_reward_cents`。
|
||||
- **`channel` 三种取值**:`clipboard`(deferred-deeplink 主路径,落地页写剪贴板、首启读回)/ `manual`(用户在邀请页手输)/ `fingerprint`(剪贴板被覆盖时走指纹兜底,见 [invite_fingerprint](./invite_fingerprint.md));三者都汇入同一个 `bind()`,只 `channel` 不同。
|
||||
- **风控缺口(#24 设计文档「上线前还差什么」标注)**:1 万金币可提现且**邀请人无总数上限**,接码平台批量注册新号绑同码即可刷;上线前需加 inviter 上限 + 基础风控。当前 MVP 仅靠"手机号唯一 + 72h 新人闸"挡。
|
||||
- **风控演进**:#24 时代的缺口是"注册即发 1 万可提现金币、接码批量刷"——**#113 把发奖后置到真实比价+下单**(且发的是邀请奖励金独立账本),批量注册空号不再直接得利,刷奖成本显著抬高;inviter 总数上限等进一步风控仍待补。
|
||||
- **alembic 多 head**:本表迁移 `invite_code_and_relation`(`down_revision=11a1d08c6f55`)与 `coupon_state_tables` 是同一父的兄弟迁移,合 main 前需建 merge 迁移,否则 prod `alembic upgrade head` 撞 `Multiple head revisions`(#24 设计文档已警示)。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# withdraw_order — 提现单(现金 → 微信零钱)
|
||||
|
||||
> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-withdraw](../api/wallet-withdraw.md) / [wallet-withdraw-status](../api/wallet-withdraw-status.md) / [wallet-withdraw-orders](../api/wallet-withdraw-orders.md) · admin [admin-withdraws-list](../api/admin-withdraws-list.md) / [admin-withdraw-refresh](../api/admin-withdraw-refresh.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-withdraw](../api/wallet/wallet-withdraw.md) / [wallet-withdraw-status](../api/wallet/wallet-withdraw-status.md) / [wallet-withdraw-orders](../api/wallet/wallet-withdraw-orders.md) · admin [admin-withdraws-list](../api/admin/withdraws/admin-withdraws-list.md) / [admin-withdraw-refresh](../api/admin/withdraws/admin-withdraw-refresh.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
用户把现金余额提到微信零钱的工单。**含人工审核**(2026-06 起):发起即扣现金、进 `reviewing` 待审核、**不打款**;管理员后台审核通过才真正发起微信商家转账,拒绝则退款。
|
||||
|
||||
@@ -26,7 +26,8 @@ reviewing ──admin 审核拒绝──▶ rejected(已退款)
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
|
||||
| `out_bill_no` | String(64) | UNIQUE, index, NOT NULL | 商户单号(幂等键 + 微信查单键)。客户端可传(`[0-9A-Za-z_-]{8,32}`),不传则服务端 `uuid4().hex`。**被 `cash_transaction.ref_id` 引用** |
|
||||
| `out_bill_no` | String(64) | UNIQUE, index, NOT NULL | 商户单号(幂等键 + 微信查单键)。客户端可传(`[0-9A-Za-z_-]{8,32}`),不传则服务端 `uuid4().hex`。**被 `cash_transaction.ref_id` / `invite_cash_transaction.ref_id` 引用**(按 `source` 落对应账本) |
|
||||
| `source` | String(16) | NOT NULL, default `coin_cash` | 提现账户来源(#121 分账):`coin_cash`(金币兑换的现金,扣 `coin_account.cash_balance_cents`、流水落 `cash_transaction`)/ `invite_cash`(邀请奖励金,扣 `invite_cash_balance_cents`、流水落 [`invite_cash_transaction`](./invite_cash_transaction.md)) |
|
||||
| `amount_cents` | Integer | NOT NULL | 提现金额(分) |
|
||||
| `user_name` | String(64) | nullable | 提现实名;微信**达额转账要求实名**,发起时存下、审核打款时传给微信 |
|
||||
| `status` | String(16) | NOT NULL, default `reviewing` | 归一化状态:`reviewing`(待审核,已扣款未打款)/ `pending`(打款在途)/ `success` / `failed`(打款失败已退)/ `rejected`(审核拒绝已退) |
|
||||
@@ -39,7 +40,7 @@ reviewing ──admin 审核拒绝──▶ rejected(已退款)
|
||||
|
||||
## 关系 / Join Key
|
||||
- `user_id` → `user.id`(多对一)。
|
||||
- `out_bill_no` ← 被 `cash_transaction.ref_id` 引用(发起 `withdraw` 一笔 −,失败/拒绝 `withdraw_refund` 一笔 +)。
|
||||
- `out_bill_no` ← 被流水 `ref_id` 引用,**账本按 `source` 分**:`coin_cash` 单 → `cash_transaction`(`withdraw` − / `withdraw_refund` +);`invite_cash` 单 → `invite_cash_transaction`(`invite_withdraw` − / `invite_withdraw_refund` +)。admin `GET /admin/api/withdraws/ledger-check` 按 source 分账做「单 ↔ 流水」对账(#121)。
|
||||
- 打款方式依赖 `wechat_transfer_authorization`(用户有生效授权 → 免确认转账,否则确认模式)。
|
||||
|
||||
## 索引与约束
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
# 领券「整单成功率」与「点位成功率」指标 — 设计与埋点
|
||||
|
||||
- 日期:2026-07-07
|
||||
- 状态:待评审
|
||||
- 涉及仓库:`shaguabijia-app-server`(**纯服务端**;客户端零改动)
|
||||
- 数据源表:`coupon_session`(admin「领券数据」看板)
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
admin「领券数据」看板(数据源 `coupon_session`,见 `app/admin/repositories/coupon_data.py`)当前能算:**领券发起数、完成数、全程耗时均值/分位**。产品还想要两个成功率指标,现有埋点算不出来:
|
||||
|
||||
- **② 整单成功率** = 一次发起里勾选的平台**全部**领到券的次数 / 领券发起数
|
||||
- **③ 点位成功率** = 平台维度的领取成功率(每个平台「点位」成没成功)
|
||||
|
||||
> 口径决定(2026-07-07):「点位」= **平台粒度**(美团 / 淘宝闪购 / 京东),不是「每张券」。
|
||||
|
||||
耗时中位数、发起数已由 `coupon_session.elapsed_ms` / `status=started` 计数满足,本设计只补 ②③。
|
||||
|
||||
## 2. 关键结论:per-slot 信号已在库,缺的是「按 session 可靠归因 + 看板可过滤」
|
||||
|
||||
服务端 `/api/v1/coupon/step`(`app/api/v1/coupon.py`)每帧都调 `record_claims`,把**每张券**的结果(`status ∈ success / already_claimed / failed / skipped`)写进 `coupon_claim_record`,还带 `trace_id`。原始成败信号**已经落库**。
|
||||
|
||||
但该表**不能**直接支撑本指标:
|
||||
|
||||
1. 唯一键是 `(device_id, coupon_id, claim_date)`,**不含 trace_id**;且 `record_claims` 冲突更新时**不更新 trace_id**(`app/repositories/coupon_state.py` 的 `record_claims`,仅 INSERT 时写 trace_id)。→ 同一张券当天跨多次 session 会折叠成一行、只归属**最早**那次 → **按 session 归因不可靠**(直接砸 ②「整单全成功」)。
|
||||
2. `coupon_claim_record` 无 `app_env` / `origin_package` → 无法像看板那样只看 prod、也无法拆 Path A(App 内发起)/ Path B(外卖侧弹券)。
|
||||
|
||||
因此采用 **route B**:在 `/step` 里服务端推导「本次 session 哪些平台成功」,直接写到 `coupon_session` 行——该表按 `trace_id` 唯一、已带 `app_env` / `origin_package` / `platforms`,指标干净可过滤、可拆路径。
|
||||
|
||||
## 3. 指标口径(平台粒度,已定)
|
||||
|
||||
记一次 session 为 `s`:
|
||||
|
||||
- `sel(s)` = **勾选平台集** = `coupon_session.platforms`;为空表示「全领」→ 取默认 `{meituan-waimai, taobao-shanguang, jd-waimai}`。`|sel(s)|` = 该次「点位数」。
|
||||
- `succ(s)` = **成功平台集** = 本次 session 里**至少领到一张**(`status ∈ {success, already_claimed}`)的平台集合。
|
||||
|
||||
指标:
|
||||
|
||||
- **③ 点位成功率** = `Σ_s |succ(s) ∩ sel(s)|` / `Σ_s |sel(s)|`
|
||||
(分母即「发起数 × 各自点位数」;全部全领时等于 发起数 × 3)
|
||||
- **② 整单成功率** = `#{ s : sel(s) ⊆ succ(s) 且 sel(s) ≠ ∅ }` / `发起数`
|
||||
|
||||
已定边界:
|
||||
|
||||
1. **③ 分母用「勾选平台」`sel(s)`**。勾了淘宝但淘宝没领到 = 该点位未成功(不特判「平台没券」)。若日后要「真没券的平台不计入分母」,再引入 `platform_attempted`(见 §8)。
|
||||
2. **②③ 基数 = 区间内全部 session**(含 `started` / `failed` / `abandoned`),与看板「发起数」同基数。**失败 / 中途退出的 session 的 `succ(s)` 取它崩溃前真领到的平台**(不一律算 0)。
|
||||
3. **成功语义**:`status ∈ {success, already_claimed}` = 成功;`failed` / `skipped` = 未成功。与 `sum_claimed_count`(`app/repositories/coupon_state.py`)一致。
|
||||
> pricebot 协议文档把 `status` enum 写作 `success|failed|skipped`,但**代码实际还会 emit `already_claimed`**(pricebot `app/services/coupon_provider.py` 等多处)——以代码为准,含 already_claimed 是对的。`skipped` 目前 MVP 阶段基本不出现。
|
||||
|
||||
## 4. 数据模型改动
|
||||
|
||||
`coupon_session`(`app/models/coupon_state.py::CouponSession`)新增一列:
|
||||
|
||||
| 列 | 类型 | 说明 |
|
||||
|----|------|------|
|
||||
| `platform_success` | `_JSON`(PG→JSONB / SQLite→JSON),nullable | 本次 session **至少领到一张**的平台 id 列表,如 `["meituan-waimai","jd-waimai"]`。旧行 = `NULL` → 视作空集。 |
|
||||
|
||||
- alembic 新迁移:`add_column coupon_session.platform_success`,nullable、无 server_default。
|
||||
- **分母 `sel(s)` 复用已有 `platforms` 列,不新增字段。** 净新增仅此一列。
|
||||
- 同步更新表字典 `docs/database/coupon_state.md`。
|
||||
|
||||
## 5. 服务端推导逻辑(`/step`)
|
||||
|
||||
在 `app/api/v1/coupon.py::coupon_step` 内(已有 `results = _extract_coupon_results(resp_json)`)新增:
|
||||
|
||||
1. 对每条 result 求平台:**按 `coupon_id` 前缀映射**(与客户端 `CouponForegroundService.couponIdToPlatform` 对齐,且与 `platforms` / `platform_elapsed` 用同一套平台 id 词表):
|
||||
- `mt_` → `meituan-waimai`
|
||||
- `tb_` / `ele_` / `elm_` → `taobao-shanguang`
|
||||
- `jd_` → `jd-waimai`
|
||||
- 其余 → 跳过(无法识别)
|
||||
> 不用券的 `vendor` 字段做映射:vendor 词表未必等于这三档平台 id,而 `sel(s)` 用的就是这三档,`succ(s)` 必须同词表。
|
||||
2. 收集 `status ∈ {success, already_claimed}` 的平台集合 `ok_platforms`。
|
||||
3. 若 `device_id` 且 `ok_platforms` 非空:调用新 repo 函数把 `ok_platforms` **并入** `coupon_session.platform_success`(按 `trace_id`)。
|
||||
|
||||
新增 `app/repositories/coupon_state.py::merge_session_platform_success(db, trace_id, ok_platforms)`:
|
||||
|
||||
- 读现有行 → `platform_success = 现有 ∪ ok_platforms`(去重、保序)→ 写回、commit;`IntegrityError` 回滚忽略(同现有 upsert 兜底)。
|
||||
- **并集幂等**:跨帧多次并入同一平台不会重复;done 帧的全量 `coupon_results` 保证完整;失败 / 中途退出的 session 靠中间帧 `last_coupon_result` 已并入的平台拿到「部分成功」。
|
||||
- **fire-and-forget**:包 `run_in_threadpool` + 整段 try/except 只 log,绝不连累 `/step` 返回(与现有 `record_claims` / `mark_completed` 同规格)。
|
||||
|
||||
**建行 / 时序(已定:行不存在则跳过本次并入)**:
|
||||
|
||||
- 客户端 `/session started` 在 `start()`(任务发起那刻)就发,而首个带券的 `/step` 要等领券循环跑起来、晚几秒;到 `/step` 有 `ok_platforms` 时,`coupon_session` 行几乎必然已存在。故 `merge_session_platform_success` **读不到行就跳过**,不建兜底行——实现最简,也不引入 `started_at` 不精确的脏行。
|
||||
- 残留丢数窗口:仅当「`started` 上报丢失」**且**「`/step` done 先于 `/session` terminal 落库」两者同时成立,该 session 的 `platform_success` 才会缺(terminal 帧会建行但那之后没有 `/step` 再并入)。两条件叠加概率极低,且指标是聚合口径、可容忍个别缺失。
|
||||
- 若上线后观测到该缺失不可忽略,再降级为「读不到行则 upsert 建最小兜底行(`started_at=now()`)」——届时改 `merge_session_platform_success` 一处即可,不影响其余设计。
|
||||
|
||||
**写放大(重要,非每 step)**:`merge_session_platform_success` **只在「本帧带券结果」时触发**——即 pricebot 在**单券完成帧**给 `last_coupon_result`、**最终 done 帧**给全量 `coupon_results` 的那些帧;领单张券途中的导航/点击帧(占 step 大头)`_extract_coupon_results` 返回空 → **不写**。所以写频次 ≈ **本次领的券数**(通常个位数),且落在**服务端今天已有的** `record_claims` 写的**同一批帧**上,不新增写的帧。
|
||||
|
||||
实现:把 merge 放进**现有 `_record_claims_blocking` 的同一个 `SessionLocal`**(紧接 `record_claims`),边际成本 = 每张券完成时多一条 `UPDATE coupon_session`(按 `trace_id` 唯一索引),不新增连接 / 不新增 `run_in_threadpool` 调用。
|
||||
|
||||
可选降级(若要「一次 session 只写一次」):只在 done 帧写 `platform_success`(全量 `coupon_results` 一次算完)。代价:`failed` / `abandoned`(没 done 帧)拿不到「崩溃前已成平台」→ 失败单部分成功丢失,与 §3「失败单取实际成的平台」相悖。**默认取每券帧并入**(失败单也如实统计),此降级留作观测到写压力后再启用。
|
||||
|
||||
## 6. admin 聚合与呈现
|
||||
|
||||
`app/admin/repositories/coupon_data.py::coupon_data_report` 的 `summary` 增加(沿用「全量拉区间 → Python 聚合」风格,与分位一致):
|
||||
|
||||
- `full_success_rate`(②)、`point_success_rate`(③)
|
||||
- 可选 `per_platform`:`{platform: rate}`(各平台点位成功率,拆美团/淘宝/京东)
|
||||
|
||||
计算:对区间内 sessions,`sel = platforms or 默认三档`,`succ = set(platform_success) ∩ sel`;按 §3 公式汇总。
|
||||
|
||||
- 过滤:默认 `app_env == 'prod'`(同现有分位口径,防测试串台)。
|
||||
- 拆路径:`origin_package` 已在表上 → 看板后续可加「Path A / Path B」筛选项(本设计不含前端图表细节)。
|
||||
- schema:`app/admin/schemas/coupon_data.py` 的 summary 加对应字段(+ 可选 `per_platform`)。
|
||||
|
||||
## 7. 改动清单
|
||||
|
||||
- [x] `app/models/coupon_state.py`:`CouponSession` 加 `platform_success`
|
||||
- [x] `alembic/versions/`:新迁移 add column `coupon_session.platform_success`
|
||||
- [x] `app/repositories/coupon_state.py`:新增 `merge_session_platform_success`
|
||||
- [x] `app/api/v1/coupon.py`:`/step` 推导 `ok_platforms` 并 union(前缀映射 + fire-and-forget)
|
||||
- [x] `app/admin/repositories/coupon_data.py`:`summary` 加 ②③(+ 可选 `per_platform`)
|
||||
- [x] `app/admin/schemas/coupon_data.py`:`summary` schema 加字段
|
||||
- [x] `docs/database/coupon_state.md`:补 `platform_success` 列说明(并补 coupon_session 整节)
|
||||
- [x] `tests/`:`tests/test_coupon_platform_success.py`(10 测试,见 §9)
|
||||
|
||||
## 8. 不做(YAGNI / 边界)
|
||||
|
||||
- **客户端不改、历史不回填**:`platform_success` 只对新 session 生效(route B 的固有取舍,用户已接受)。
|
||||
- **不引入 `platform_attempted`**:③ 分母用勾选平台。若日后要「排除真没券的平台」,注意 `platform_elapsed.keys()` 已近似「被处理过的平台」,可作 attempted 的现成来源,多半仍不必加列。
|
||||
- **不动 `coupon_claim_record`** 的去重 / 归因:本指标绕开它,避免牵动频控 / 资产 / CPS 语义。
|
||||
- **不做券级(每张券)成功率**:已选平台粒度。
|
||||
|
||||
## 9. 测试口径要点
|
||||
|
||||
- **union 幂等**:同一 `trace_id` 多帧并入同一平台,`platform_success` 不重复、保序。
|
||||
- **失败单部分成功**:session `failed`,但美团已成 → `succ = {meituan-waimai}`,计入 ③ 分子;② 仅当 `sel ⊆ succ` 才算整单成功。
|
||||
- **空 `platforms` → `sel` 取默认三档**(全领)。
|
||||
- **成功语义**:`already_claimed` 计成功;`skipped` 不计。
|
||||
- **基数**:`abandoned` 计入 ②③ 基数,`succ` 取实际成的平台。
|
||||
- **prod 过滤**:`dev` 环境 session 不进指标。
|
||||
|
||||
## 10. 对 pricebot-backend 的影响
|
||||
|
||||
**结论:不需要改 pricebot,也不改发往 pricebot 的请求 / 不加调用 / 不加负载。**
|
||||
|
||||
- `/step` 仍原样透传请求 bytes 给 pricebot;本设计只**多解析 pricebot 的响应**(`coupon_results` / `last_coupon_result`),而这两个字段服务端**今天已在** `_extract_coupon_results` / `record_claims` 里解析。零新增字段需求、零额外上游调用、pricebot 负载不变。
|
||||
- **只读依赖(既有耦合,非新引入)**:平台映射靠 pricebot 的 `coupon_id` 前缀约定(`mt_` / `tb_` / `ele_` / `elm_` / `jd_`)。客户端 `couponIdToPlatform` 早就依赖同一套;本设计只是加了这份映射的第二个消费者。维护耦合:pricebot 若改 `coupon_id` 前缀,客户端与本指标会**一起**失效——但这是既有风险,依赖方向不变。无法识别前缀的券按「跳过」处理(与客户端一致)。
|
||||
- 认账的 pricebot 事实(源:`app/models/response.py` + `docs/projects/领券-客户端对接协议.md`):
|
||||
- `CouponResult = {coupon_id, name, vendor, status, reason?, duration_ms?}`;`coupon_results` 仅最终 done 帧全量,中间帧走 `last_coupon_result`(单张)。
|
||||
- `vendor` 是来源标签(`meituan_internal` / `dianping_cps` …),**不等于**三档平台 id;且 `mt_dianping_xxx`(大众点评 CPS)也带 `mt_` 前缀归美团 → 印证「用 `coupon_id` 前缀、不用 `vendor`」正确。
|
||||
|
||||
---
|
||||
|
||||
## 11. 实现状态(交付记录 · 2026-07-07)
|
||||
|
||||
**状态:实现完成、TDD 全绿、未提交、迁移未应用。** 纯服务端(shaguabijia-app-server),客户端 / pricebot 未动。
|
||||
|
||||
### 已交付改动
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `app/models/coupon_state.py` | `CouponSession` 加 `platform_success`(`_JSON`, nullable) |
|
||||
| `app/repositories/coupon_state.py` | `coupon_id_to_platform` / `succeeded_platforms` / `merge_session_platform_success` + 常量 `DEFAULT_PLATFORMS` / `_SUCCESS_STATUSES` |
|
||||
| `app/api/v1/coupon.py` | `_record_claims_blocking` 内、`record_claims` 之后并入本帧成功平台(同一 `SessionLocal`) |
|
||||
| `app/admin/repositories/coupon_data.py` | `_success_rates(rows)` → summary 加 `full_success_rate②` / `point_success_rate③` + 3 个支撑计数 |
|
||||
| `app/admin/schemas/coupon_data.py` | `CouponDataSummary` 加 5 字段 |
|
||||
| `alembic/versions/coupon_session_platform_success.py` | add column;revision=`coupon_session_platform_success`,down=`admin_user_plain_password`(当前 head) |
|
||||
| `docs/database/coupon_state.md` | 补 `coupon_session` 整节(表原本无文档)+ 新列 |
|
||||
| `tests/test_coupon_platform_success.py` | 10 个测试(TDD) |
|
||||
|
||||
> §7 里「可选 `per_platform`」原标 YAGNI 延后;**已在 §12(2026-07-08)补做**(随 admin 前端看板卡一并接入,见下)。
|
||||
|
||||
### 测试与验证
|
||||
- 本特性:`Set-Location e:\project\shaguabijia-app-server; & .\.venv\Scripts\python.exe -m pytest tests\test_coupon_platform_success.py -q` → **10 passed**。
|
||||
- 全量 `pytest -q`:**306 passed / 5 failed**。5 个失败**全部预存、与本次无关**:
|
||||
- `test_coupon_proxy.py::test_coupon_step_passes_body_through`(测试断言 `json=` 但 handler 用 `content=` 转发;写代码前 sanity run 就红)。
|
||||
- `test_invite.py` + `test_invite_compare_reward.py` 共 4 个(单独跑也红;本次改动集零 invite 文件)。
|
||||
- 迁移:临时库 `alembic upgrade head` 通过、列已建、单一 head。
|
||||
- lint:新增代码 `ruff` 全清;`coupon_state.py` 剩 3 处 pre-existing UP017(`upsert_coupon_session` 的 `timezone.utc`)未动。
|
||||
|
||||
### 待办(在后端项目里继续)
|
||||
1. **应用迁移**:`alembic upgrade head`(DDL 已验;线上只前进统计、历史不回填)。
|
||||
2. **提交**:尚未提交;建议先开分支再提交。
|
||||
3. **admin 前端图表**:后端指标已就绪(summary 的 `full_success_rate` / `point_success_rate` 等),看板卡 / 趋势展示待接前端。
|
||||
4. (可选)修预存 `test_coupon_step_passes_body_through`(一行:capture `content` 而非 `json`)。
|
||||
|
||||
### 续开发须知
|
||||
- `platform_success` 只在**带券结果的帧**写(`/step` 里 `succeeded_platforms(results)` 非空才 merge),≈ 领券券数量级、非每 step;复用 `record_claims` 同一 `SessionLocal`、不新增连接。
|
||||
- merge **读不到 session 行则跳过**(不建兜底行);并集幂等、无新平台不写。
|
||||
- 口径:成功=`status∈{success,already_claimed}`;基数含 `abandoned`/`failed`;③ 分母=`platforms`(空→全领三档);`coupon_id`→平台走前缀,与客户端 `couponIdToPlatform` 同词表。
|
||||
|
||||
---
|
||||
|
||||
## 12. 续做:admin 前端看板卡 + 分平台点位成功率(设计 · 2026-07-08)
|
||||
|
||||
承 §11 待办 #3(前端图表)与 §7「可选 `per_platform`」。本轮把 ②③ 接入 admin「领券数据」页,并把 ③ 按平台拆(`per_platform`)。**改前端 + 后端 + 测试**;客户端 / pricebot 仍零改动。
|
||||
|
||||
- 状态:设计已评审通过(2026-07-08),待实现。
|
||||
- 涉及仓库:`shaguabijia-app-server`(后端)+ `shaguabijia-admin-web`(admin 前端,Next.js + antd)。
|
||||
|
||||
### 12.1 后端:`per_platform` 分平台点位成功率
|
||||
|
||||
`app/admin/repositories/coupon_data.py::_success_rates(rows)` 在现有合计基础上,对每个 `p ∈ DEFAULT_PLATFORMS`(美团/淘宝/京东)累加:
|
||||
|
||||
- 分母 `per_total[p]` = 勾选了 p 的 session 数(`p ∈ sel`,空勾选 `sel` 按全领三档);
|
||||
- 分子 `per_succ[p]` = 其中 `p ∈ succ`(该平台至少领到一张)的 session 数;
|
||||
- `per_platform[p]` = `round(per_succ[p] / per_total[p], 4)`;分母 0 → `None`。
|
||||
|
||||
**不变量**:`Σ_p per_succ[p] == point_success_count`、`Σ_p per_total[p] == point_total_count`(三档词表下恒成立;非三档平台 id 不计入 `per_platform`,由 `if p in per_total` 守卫)。用 §9 / `test_coupon_data_success_rates` 数据自检:美团 3/4=0.75、淘宝 2/3=0.6667、京东 1/2=0.5;合计仍 6/9=0.6667。
|
||||
|
||||
- summary 加一项 `per_platform`:**恒含三档键**,如 `{"meituan-waimai":0.75,"taobao-shanguang":0.6667,"jd-waimai":0.5}`(区间内无人勾选的平台 → 值 `None`)。
|
||||
- schema `app/admin/schemas/coupon_data.py::CouponDataSummary` 加 `per_platform: dict[str, float | None]`。
|
||||
|
||||
### 12.2 前端:admin-web「领券数据」汇总卡补一段
|
||||
|
||||
`shaguabijia-admin-web/src/app/(main)/coupon-data/page.tsx`(单文件,`CouponDataSummary` 为该页内联类型):
|
||||
|
||||
- 内联 `CouponDataSummary` 补:`full_success_count` / `full_success_rate` / `point_success_count` / `point_total_count` / `point_success_rate` + `per_platform: Record<string, number | null>`。
|
||||
- 新 helper `fmtPct(v) = v == null ? '-' : ${(v*100).toFixed(1)}%`(沿用本页 `-` 空值风格,数学同大盘 `pct`);antd 导入补 `Tooltip`,新增 `import { InfoCircleOutlined } from '@ant-design/icons'`。
|
||||
- 汇总卡「耗时分位」行之后,`Divider` + 两行 `Statistic`(各 `Col flex="1 1 0"`):
|
||||
- 行1:**整单成功率** `fmtPct(full_success_rate)` · **点位成功率(合计)** `fmtPct(point_success_rate)`;标题各带 ⓘ `Tooltip`(口径说明;合计率注明「平台粒度、= 三档之和,与『数据大盘』券粒度口径不同」)。
|
||||
- 行2:**美团 / 淘宝 / 京东 点位成功率** `fmtPct(per_platform['meituan-waimai' | 'taobao-shanguang' | 'jd-waimai'])`(平台名同「美团耗时」列既有叫法)。
|
||||
- 不显示支撑数(合计与分平台均纯百分比);tooltip 不带分母。
|
||||
|
||||
### 12.3 测试
|
||||
|
||||
- 扩 `tests/test_coupon_platform_success.py::test_coupon_data_success_rates`:断言 `per_platform == {"meituan-waimai":0.75,"taobao-shanguang":round(2/3,4),"jd-waimai":0.5}`,并断言和不变量(`Σ 分子 == point_success_count == 6`、`Σ 分母 == point_total_count == 9`)。
|
||||
- 前端 `shaguabijia-admin-web` lint / type-check 通过。
|
||||
- 端到端:随下一步「真实 /step 实测」在跑起来的 admin-web + 后端页面上核对卡片渲染。
|
||||
|
||||
### 12.4 不做(YAGNI)
|
||||
|
||||
- 成功率**趋势线**:后端 `daily` / `hourly` 不含率字段,加趋势要另改聚合,超出本轮范围。
|
||||
- 合计 / 分平台的**支撑数副文本**、tooltip 带分母。
|
||||
- 客户端 / pricebot 改动;历史回填。
|
||||
|
||||
---
|
||||
|
||||
## 13. 续做:每券(coupon_id)成功率明细表(设计 · 2026-07-08)
|
||||
|
||||
产品要更细粒度:到**具体领券点位**(如「美团外卖红包天天领」「美团甄选好店」),即按 `coupon_id` 算成功/失败率,比 §12 的平台粒度再细一层。**改前端 + 后端 + 测试**;客户端 / pricebot 仍零改动。
|
||||
|
||||
- 状态:设计已评审通过(2026-07-08),待实现。
|
||||
- 关键取舍(已定):数据源用 **`coupon_claim_record`**(它本就是「单张券一天一条」的系统记录,已带 `coupon_name` / `status` / `vendor` / `trace_id`),唯一缺 `app_env` → 补一列即可。§2 当初绕开它是因为**平台指标要按 session 归因**;而**每券成功率是全局聚合、不需要 session 归因**,`(device,券,天)` 折叠反而天然去重防刷,故这里用它是对的。
|
||||
|
||||
### 13.1 指标口径(已定)
|
||||
|
||||
- **成功** = `status ∈ {success, already_claimed}`;**尝试(分母)** = `status ∈ {success, already_claimed, failed}`;**`skipped` 排除**(无券可领/不适用,不算尝试、不进分母、不展示)。
|
||||
- **成功率** = 成功 / 尝试(某券区间内无 tried 行 → 不出现在表里,无除零)。
|
||||
- **粒度 = 「设备-天」**(非「每次点击」):`coupon_claim_record` 唯一键 `(device, coupon_id, claim_date)`,同设备当天同券只留最后状态。所以「尝试」= 有多少**设备-天**尝试过该券,「成功」= 其中最终领到的。要每次点击级须换源(route B / 原始事件),本轮不做。
|
||||
- **环境**:跟随页面「环境」(prod/dev/全部),按 `app_env` 过滤;旧行 `app_env=NULL` **不回填** → 仅「全部」视图可见。
|
||||
- **日期**:按 `claim_date`(Asia/Shanghai 自然日),与页面日期范围一致。
|
||||
|
||||
### 13.2 数据模型
|
||||
|
||||
`CouponClaimRecord`(`app/models/coupon_state.py`)加一列:
|
||||
|
||||
| 列 | 类型 | 说明 |
|
||||
|----|------|------|
|
||||
| `app_env` | `String(16)`,index,nullable | prod / dev。`/step` 落库时按 session 的 app_env 打标;旧行 NULL(不回填)。 |
|
||||
|
||||
- alembic 新迁移:`add_column coupon_claim_record.app_env`,nullable + index,无 server_default。
|
||||
- 同步更新 `docs/database/coupon_state.md` 的 coupon_claim_record 节。
|
||||
|
||||
### 13.3 写路径(`/step`)
|
||||
|
||||
- `app/repositories/coupon_state.py::record_claims` 加参数 `app_env: str | None = None`;INSERT 时写入,UPDATE 时 `if app_env is not None: row.app_env = app_env`(不用 None 覆盖已有)。
|
||||
- 新增轻量 repo 助手 `session_app_env(db, trace_id) -> str | None`(按 trace_id 取 `coupon_session.app_env`,查不到返回 None)。
|
||||
- `app/api/v1/coupon.py::_record_claims_blocking`:`trace_id` 存在则先 `app_env = coupon_repo.session_app_env(db, trace_id)`,传给 `record_claims(..., app_env=app_env)`;`merge_session_platform_success` 保持原样(其自身 select 不变)。查不到 session / 无 trace_id → `app_env=None`(行为同旧)。
|
||||
> 写放大:仅「带券结果的帧」触发(同 §5),每帧多一次 `session_app_env` 小查询(按 trace_id 唯一索引),可忽略。
|
||||
|
||||
### 13.4 admin 聚合(新 repo 函数)
|
||||
|
||||
`app/admin/repositories/coupon_data.py` 新增 `coupon_slot_report(db, *, date_from, date_to, app_env)`(与 `coupon_user_records` 同居本文件,同属「领券数据」看板;更新模块 docstring 注明本文件现读 `coupon_session` + `coupon_claim_record` 两源):
|
||||
|
||||
- `SELECT coupon_id, MAX(coupon_name) AS coupon_name, COUNT(*) AS tried, SUM(CASE WHEN status IN (success,already_claimed) THEN 1 ELSE 0 END) AS succeeded`
|
||||
`WHERE claim_date ∈ [from,to] AND status IN (success,already_claimed,failed)` (+ `AND app_env = :env` 当 env 非「全部」) `GROUP BY coupon_id`。
|
||||
- 每行:`platform = coupon_id_to_platform(coupon_id)`(复用前缀映射,无法识别→None),`success_rate = round(succeeded/tried, 4)`。
|
||||
- 按 `tried` 倒序、`coupon_id` 次序返回 `{"items": [...]}`。
|
||||
|
||||
### 13.5 接口 + schema(新子端点)
|
||||
|
||||
- 路由 `app/admin/routers/coupon_data.py`:加 `@router.get("/coupons")` → `get_coupon_slots`,参数 `date_from` / `date_to` / `app_env`(同主 report 的解析与 `_MAX_RANGE_DAYS` 校验,`app_env="all"→None`),调 `coupon_slot_report`。路径全称 `/admin/api/coupon-data/coupons`(与现有 `/coupon-data/user-records` 同款子端点)。
|
||||
- schema `app/admin/schemas/coupon_data.py`:
|
||||
- `CouponSlotRow{coupon_id: str, coupon_name: str|None, platform: str|None, tried: int, succeeded: int, success_rate: float|None}`
|
||||
- `CouponSlotsOut{date_from: str, date_to: str, items: list[CouponSlotRow]}`
|
||||
|
||||
### 13.6 前端
|
||||
|
||||
`shaguabijia-admin-web/src/app/(main)/coupon-data/page.tsx`:
|
||||
|
||||
- 汇总卡下方(或趋势图下方)新增一张「按券成功率」表:列 **券名**(`coupon_name || coupon_id`)/ **平台**(美团/淘宝/京东/其他)/ **尝试** / **成功** / **成功率**(`fmtPct`)。默认按尝试倒序;支持 antd 列排序。
|
||||
- 点「查询」时,除主 report 外并行 `api.get('/admin/api/coupon-data/coupons', {params:{date_from,date_to,app_env}})`;空则不显示表。
|
||||
- 新增内联类型 `CouponSlotRow`;平台名复用映射(`meituan-waimai→美团` 等,null→其他)。
|
||||
|
||||
### 13.7 测试
|
||||
|
||||
- 后端:`record_claims` stamp `app_env`(INSERT/UPDATE 两路);`session_app_env` 助手;`/step` 集成写入 `app_env`;`coupon_slot_report` 聚合(多券 × success/already_claimed/failed/skipped × 两 env:断言 tried/succeeded/rate、**skipped 排除**、env 过滤、排序);schema 契约。
|
||||
- 前端:`tsc --noEmit`。
|
||||
|
||||
### 13.8 不做(YAGNI)
|
||||
|
||||
- 每次点击级成功率(需换数据源);成功率趋势线;`app_env` 历史回填(可选一次性 `join trace_id→session.app_env`,默认不做);券级 tooltip / 明细下钻。
|
||||
- 券表为**全局聚合**,**不随页面「用户」搜索框过滤**(用户维度非本需求;`coupon_claim_record` 虽有 `user_id`,YAGNI)。与主明细表按用户过滤的行为不同,属有意为之。
|
||||
@@ -0,0 +1,830 @@
|
||||
# 逐次比价/领券广告收益 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 让 admin「领券数据」和「比价记录」两个看板的 table 每一行显示这一次领券/比价产生的广告收益(预估元)。
|
||||
|
||||
**Architecture:** 客户端在信息流(Draw)展示上报 eCPM 时带上本场 `trace_id`,后端落到 `ad_ecpm_record.trace_id`(新列 + 索引)。两个看板在分页后,对**当前页**的 trace_id 批量聚合一次 `ad_ecpm_record` 的展示收益(单条收益 = min(eCPM元,¥500)/1000,与广告收益报表同口径),挂到每行。查询是按 trace_id 索引的单条聚合,与已上线的 `_ad_coins_by_trace`(比价记录页「比价赚N金币」)同一性能剖面。
|
||||
|
||||
**Tech Stack:** 后端 FastAPI + SQLAlchemy 2.0 + Alembic;客户端 Android(Kotlin/OkHttp);admin 前端 Next.js + React + Ant Design(shaguabijia-admin-web)。
|
||||
|
||||
---
|
||||
|
||||
## 背景与约束(执行前必读)
|
||||
|
||||
- **收益 ≠ 金币**。本功能查的是「我们赚的广告收益」(数据源 `ad_ecpm_record`,客户端自报 eCPM 折算的预估),不是发给用户的金币(那是 `ad_feed_reward_record`,已有 `trace_id`)。
|
||||
- **只能到「场景 + 单次」粒度**。`trace_id` 由客户端在比价(comparisonTraceId)/领券(sessionTraceId)全流程保持不变。激励视频、福利页、旧客户端不带 trace_id → 该列为 NULL,历史数据无法回填,只对升级后新数据生效。
|
||||
- **收益口径**(与 `app/admin/repositories/ad_revenue.py:165-167` 完全一致):
|
||||
单条展示收益(元) = `min(parse_ecpm_yuan(ecpm_raw), AD_ECPM_MAX_FEN/100) / 1000`
|
||||
其中 `AD_ECPM_MAX_FEN = 50000`(分)= ¥500 CPM 封顶,`parse_ecpm_yuan(x) = parse_ecpm_fen(x)/100`。
|
||||
- **性能前提**:`ad_ecpm_record` 是全库写入量最大的表。必须有 `ix_ad_ecpm_record_trace_id` 索引(本计划 Task A2 建),且只对**当前页**的 trace_id 聚合,绝不对整个日期区间聚合。
|
||||
- **跨仓库**:本计划涉及三个仓库,路径前缀:
|
||||
- 后端 `e:\project\shaguabijia-app-server`(相对路径即以此为根)
|
||||
- 客户端 `E:\project\shaguabijia-app-android`
|
||||
- admin 前端 `e:\project\shaguabijia-admin-web`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### 后端(shaguabijia-app-server)
|
||||
- Modify `app/models/ad_ecpm.py` — `AdEcpmRecord` 加 `trace_id` 列(索引)
|
||||
- Create `alembic/versions/ad_ecpm_trace_id.py` — 加列 + 索引,并收敛当前双 head
|
||||
- Modify `app/schemas/ad.py` — `EcpmReportIn` 加 `trace_id` 字段
|
||||
- Modify `app/repositories/ad_ecpm.py` — `create_ecpm_record` 持久化 `trace_id`;新增 `revenue_yuan_by_trace` 聚合器
|
||||
- Modify `app/api/v1/ad.py` — `ecpm_report` 透传 `trace_id`
|
||||
- Modify `app/admin/schemas/coupon_data.py` — `CouponDataRow` 加 `ad_revenue_yuan`
|
||||
- Modify `app/admin/repositories/coupon_data.py` — 逐页补 `ad_revenue_yuan`
|
||||
- Modify `app/admin/schemas/comparison.py` — `AdminComparisonListItem` 加 `ad_revenue_yuan`
|
||||
- Modify `app/admin/repositories/queries.py` — `list_comparison_records` 逐页补 `ad_revenue_yuan`
|
||||
- Create `tests/test_ad_ecpm_trace_revenue.py` — 聚合器 + 落库单测
|
||||
- Create `tests/test_board_ad_revenue.py` — 两个看板收益列单测
|
||||
|
||||
> `app/models/__init__.py` **不需改**:`AdEcpmRecord` 已注册,只是加列。
|
||||
|
||||
### 客户端(shaguabijia-app-android)
|
||||
- Modify `app/src/main/java/com/jishisongfu/shaguabijia/agent/network/ApiClient.kt` — `reportAdImpression` 加 `traceId` 参数
|
||||
- Modify `app/src/main/java/com/jishisongfu/shaguabijia/agent/service/ad/CompareAdController.kt` — 比价展示上报带 `traceId`
|
||||
- Modify `app/src/main/java/com/jishisongfu/shaguabijia/service/CouponForegroundService.kt` — 领券展示上报带 `traceId`
|
||||
|
||||
### admin 前端(shaguabijia-admin-web)
|
||||
- Modify `src/lib/types.ts` — `ComparisonRecordListItem` 加 `ad_revenue_yuan`
|
||||
- Modify `src/app/(main)/comparison-records/page.tsx` — 加「广告收益」列
|
||||
- Modify `src/app/(main)/coupon-data/page.tsx` — `CouponDataRow` 加字段 + 加「广告收益」列
|
||||
|
||||
---
|
||||
|
||||
## Phase A — 后端数据打通(落 trace_id + 收益聚合器)
|
||||
|
||||
### Task A1: `AdEcpmRecord` 加 `trace_id` 列
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/models/ad_ecpm.py`
|
||||
|
||||
- [ ] **Step 1: 加列**
|
||||
|
||||
在 `app/models/ad_ecpm.py` 中,找到 `feed_scene` 这一行:
|
||||
|
||||
```python
|
||||
feed_scene: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
```
|
||||
|
||||
在其**下方**插入:
|
||||
|
||||
```python
|
||||
# 本次比价/领券 trace_id(信息流场景客户端带上):把这条展示收益归属到对应比价/领券记录。
|
||||
# 领券数据 / 比价记录看板按 trace_id 聚合"本次广告收益"。激励视频/福利/旧客户端 = NULL。
|
||||
trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add app/models/ad_ecpm.py
|
||||
git commit -m "feat(ad-ecpm): add trace_id column to AdEcpmRecord model"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task A2: 迁移 — 加列 + 索引,并收敛双 head
|
||||
|
||||
**Files:**
|
||||
- Create: `alembic/versions/ad_ecpm_trace_id.py`
|
||||
|
||||
> ⚠️ 当前 `alembic heads` 有**两个 head**:`11c44afbea58`(selfstat 表)与 `merge_pages_override_coupon_slot`(#126+领券合并)。本迁移用元组 `down_revision` 把二者收敛成单 head,同时加列,让 `alembic upgrade head`(单数,run.sh 用)恢复正常。
|
||||
|
||||
- [ ] **Step 1: 确认当前 heads 未漂移**
|
||||
|
||||
Run: `alembic heads`
|
||||
Expected: 恰好两行 —
|
||||
```
|
||||
11c44afbea58 (head)
|
||||
merge_pages_override_coupon_slot (head)
|
||||
```
|
||||
若不同(他人已合并/新增),把下面 `down_revision` 改成此刻实际的 head 列表。
|
||||
|
||||
- [ ] **Step 2: 建迁移文件**
|
||||
|
||||
Create `alembic/versions/ad_ecpm_trace_id.py`:
|
||||
|
||||
```python
|
||||
"""ad_ecpm_record.trace_id(展示收益归属到比价/领券 trace)+ 收敛双 head
|
||||
|
||||
信息流(Draw)展示 eCPM 上报时带上本场比价/领券 trace_id,落此列;领券数据 / 比价记录看板
|
||||
按 trace_id 聚合"本次广告收益"。激励视频/福利/旧客户端为 NULL。
|
||||
|
||||
顺带把当前两个 head(11c44afbea58 selfstat 表 + merge_pages_override_coupon_slot)收敛成
|
||||
单 head,让 `alembic upgrade head`(单数,部署/run.sh 用)恢复正常。
|
||||
|
||||
Revision ID: ad_ecpm_trace_id
|
||||
Revises: 11c44afbea58, merge_pages_override_coupon_slot
|
||||
Create Date: 2026-07-10
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "ad_ecpm_trace_id"
|
||||
down_revision: Union[str, Sequence[str], None] = (
|
||||
"11c44afbea58",
|
||||
"merge_pages_override_coupon_slot",
|
||||
)
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# SQLite 下 ADD COLUMN(可空)与 CREATE INDEX 均原生支持,无需 batch_alter_table
|
||||
# (同 ad_feed_reward_trace_id 迁移)。
|
||||
op.add_column(
|
||||
"ad_ecpm_record",
|
||||
sa.Column("trace_id", sa.String(length=64), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_ad_ecpm_record_trace_id"),
|
||||
"ad_ecpm_record",
|
||||
["trace_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_ad_ecpm_record_trace_id"), table_name="ad_ecpm_record")
|
||||
op.drop_column("ad_ecpm_record", "trace_id")
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 应用迁移**
|
||||
|
||||
Run: `alembic upgrade head`
|
||||
Expected: 无报错(不再报 "multiple heads")。
|
||||
|
||||
- [ ] **Step 4: 验证单 head + 列存在**
|
||||
|
||||
Run: `alembic heads`
|
||||
Expected: 只有一行 `ad_ecpm_trace_id (head)`。
|
||||
|
||||
Run: `python -c "from sqlalchemy import inspect; from app.db.session import engine; print([c['name'] for c in inspect(engine).get_columns('ad_ecpm_record')])"`
|
||||
Expected: 输出的列名列表包含 `trace_id`。
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add alembic/versions/ad_ecpm_trace_id.py
|
||||
git commit -m "feat(migration): add ad_ecpm_record.trace_id + index, converge heads"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task A3: `EcpmReportIn` 加 `trace_id` 字段
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/schemas/ad.py`
|
||||
|
||||
- [ ] **Step 1: 加字段**
|
||||
|
||||
在 `app/schemas/ad.py` 的 `EcpmReportIn` 里,找到 `feed_scene` 字段定义(以 `feed_scene: str | None = Field(` 开头的那段)。在该字段**之后**插入:
|
||||
|
||||
```python
|
||||
trace_id: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="本次比价/领券 trace_id(信息流场景带上):把这条展示收益归属到对应比价/领券,"
|
||||
"供领券数据/比价记录看板聚合本场广告收益;激励视频/福利为空",
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add app/schemas/ad.py
|
||||
git commit -m "feat(ad-schema): EcpmReportIn accepts trace_id"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task A4: `create_ecpm_record` 持久化 trace_id + 新增 `revenue_yuan_by_trace`
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/repositories/ad_ecpm.py`
|
||||
- Test: `tests/test_ad_ecpm_trace_revenue.py`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
|
||||
Create `tests/test_ad_ecpm_trace_revenue.py`:
|
||||
|
||||
```python
|
||||
"""ad_ecpm_record.trace_id 落库 + 按 trace 聚合广告收益(元)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import delete
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.ad_ecpm import AdEcpmRecord
|
||||
from app.repositories import ad_ecpm as crud_ecpm
|
||||
|
||||
|
||||
def _ecpm(trace_id: str, ecpm_raw: str, session_id: str) -> AdEcpmRecord:
|
||||
"""构造一条 Draw 展示 eCPM(不 commit;ad_session_id 全局唯一,须各不相同)。"""
|
||||
return AdEcpmRecord(
|
||||
user_id=1,
|
||||
ad_type="draw",
|
||||
feed_scene="comparison",
|
||||
ad_session_id=session_id,
|
||||
ecpm_raw=ecpm_raw,
|
||||
trace_id=trace_id,
|
||||
report_date="2020-01-02",
|
||||
created_at=datetime(2020, 1, 2, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
def test_revenue_yuan_by_trace_sums_and_clamps() -> None:
|
||||
"""同一 trace 多条展示求和;收益=min(eCPM元,¥500)/1000;无展示的 trace 不出现。
|
||||
|
||||
ecpm 200 分→2.0 元/千次→0.002 元/次;300 分→0.003;合计 0.005。
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add_all([
|
||||
_ecpm("t1", "200", "sess-t1-a"),
|
||||
_ecpm("t1", "300", "sess-t1-b"),
|
||||
_ecpm("t2", "0", "sess-t2-a"),
|
||||
])
|
||||
db.flush()
|
||||
rev = crud_ecpm.revenue_yuan_by_trace(db, ["t1", "t2", "t3"])
|
||||
assert rev["t1"] == 0.005
|
||||
assert rev.get("t2", 0.0) == 0.0
|
||||
assert "t3" not in rev # 无展示的 trace 不出现在结果里
|
||||
finally:
|
||||
db.rollback()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_revenue_yuan_by_trace_empty() -> None:
|
||||
"""空 trace 列表直接返回 {}(避免 IN () 非法)。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
assert crud_ecpm.revenue_yuan_by_trace(db, []) == {}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_create_ecpm_record_persists_trace_id() -> None:
|
||||
"""create_ecpm_record 落 trace_id。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rec = crud_ecpm.create_ecpm_record(
|
||||
db, 1, ad_type="draw", ecpm_raw="150",
|
||||
ad_session_id="sess-trace-persist", feed_scene="coupon",
|
||||
trace_id="trace-xyz",
|
||||
)
|
||||
assert rec.trace_id == "trace-xyz"
|
||||
finally:
|
||||
db.execute(delete(AdEcpmRecord).where(AdEcpmRecord.ad_session_id == "sess-trace-persist"))
|
||||
db.commit()
|
||||
db.close()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
|
||||
Run: `pytest tests/test_ad_ecpm_trace_revenue.py -q`
|
||||
Expected: FAIL — `create_ecpm_record` 无 `trace_id` 参数(TypeError)/ `revenue_yuan_by_trace` 不存在(AttributeError)。
|
||||
|
||||
- [ ] **Step 3: 实现**
|
||||
|
||||
在 `app/repositories/ad_ecpm.py`:
|
||||
|
||||
(a) 顶部 import 区加(与现有 `from app.core.rewards import cn_today` 并列):
|
||||
|
||||
```python
|
||||
from app.core import rewards
|
||||
```
|
||||
|
||||
(b) `create_ecpm_record` 的签名里,在 `feed_scene: str | None = None,` 之后加一行参数:
|
||||
|
||||
```python
|
||||
trace_id: str | None = None,
|
||||
```
|
||||
|
||||
(c) 同函数体内构造 `AdEcpmRecord(...)` 处,在 `feed_scene=feed_scene,` 之后加一行:
|
||||
|
||||
```python
|
||||
trace_id=trace_id,
|
||||
```
|
||||
|
||||
(d) 文件末尾新增聚合器:
|
||||
|
||||
```python
|
||||
def revenue_yuan_by_trace(db: Session, trace_ids: list[str]) -> dict[str, float]:
|
||||
"""各 trace_id 的广告预估收益(元):按 trace_id 聚合 ad_ecpm_record 的展示收益。
|
||||
|
||||
单条展示收益 = min(eCPM元, AD_ECPM_MAX_FEN/100) / 1000(与 admin 广告收益报表同口径)。
|
||||
ecpm_raw 是字符串且需逐条钳顶,故取回后 Python 求和(行数=本页各 trace 的展示条数,很小)。
|
||||
trace_id 仅信息流(比价/领券)场景客户端带,激励视频/旧数据为 NULL,按 trace_id 过滤天然只算对应场景。
|
||||
只喂**当前页**的 trace_id(≤ 一页条数);空集合直接返回(避免 IN () 非法)。
|
||||
"""
|
||||
if not trace_ids:
|
||||
return {}
|
||||
rows = db.execute(
|
||||
select(AdEcpmRecord.trace_id, AdEcpmRecord.ecpm_raw).where(
|
||||
AdEcpmRecord.trace_id.in_(trace_ids),
|
||||
)
|
||||
).all()
|
||||
cap_yuan = rewards.AD_ECPM_MAX_FEN / 100.0
|
||||
out: dict[str, float] = {}
|
||||
for tid, ecpm_raw in rows:
|
||||
if not tid:
|
||||
continue
|
||||
out[tid] = out.get(tid, 0.0) + min(rewards.parse_ecpm_yuan(ecpm_raw), cap_yuan) / 1000.0
|
||||
return {tid: round(v, 6) for tid, v in out.items()}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过**
|
||||
|
||||
Run: `pytest tests/test_ad_ecpm_trace_revenue.py -q`
|
||||
Expected: PASS(3 passed)。
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add app/repositories/ad_ecpm.py tests/test_ad_ecpm_trace_revenue.py
|
||||
git commit -m "feat(ad-ecpm): persist trace_id + revenue_yuan_by_trace aggregator"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task A5: `ecpm_report` 端点透传 trace_id
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/api/v1/ad.py`
|
||||
|
||||
- [ ] **Step 1: 透传字段**
|
||||
|
||||
在 `app/api/v1/ad.py` 的 `ecpm_report` 函数里,找到 `crud_ecpm.create_ecpm_record(` 调用,在 `feed_scene=payload.feed_scene,` 之后加一行:
|
||||
|
||||
```python
|
||||
trace_id=payload.trace_id,
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 冒烟验证(手动,可选)**
|
||||
|
||||
启动后端(`./run.sh`),用一个有效用户 JWT 调:
|
||||
|
||||
Run:
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:8770/api/v1/ad/ecpm-report \
|
||||
-H "Authorization: Bearer <USER_JWT>" -H "Content-Type: application/json" \
|
||||
-d '{"ad_type":"draw","ecpm":"200","ad_session_id":"smoke-sess-1","feed_scene":"comparison","trace_id":"smoke-trace-1"}'
|
||||
```
|
||||
Expected: `{"ok":true}`;库里 `ad_ecpm_record` 出现一条 `trace_id='smoke-trace-1'` 的记录。
|
||||
|
||||
> 端点逻辑是纯透传,已由 A4 的 schema/repo 测试覆盖;此步仅人工确认接线。
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add app/api/v1/ad.py
|
||||
git commit -m "feat(ad-api): ecpm-report forwards trace_id to record"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase B — 后端两个看板补收益列
|
||||
|
||||
### Task B1: 领券数据看板逐行补 `ad_revenue_yuan`
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/admin/schemas/coupon_data.py`
|
||||
- Modify: `app/admin/repositories/coupon_data.py`
|
||||
- Test: `tests/test_board_ad_revenue.py`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
|
||||
Create `tests/test_board_ad_revenue.py`:
|
||||
|
||||
```python
|
||||
"""两个看板逐行「本次广告收益」(元):按 trace_id 聚合 ad_ecpm_record。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
|
||||
from sqlalchemy import delete
|
||||
|
||||
from app.admin.repositories import queries
|
||||
from app.admin.repositories.coupon_data import coupon_data_report
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.ad_ecpm import AdEcpmRecord
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.coupon_state import CouponSession
|
||||
|
||||
|
||||
def _ecpm(trace_id: str, ecpm_raw: str, session_id: str, scene: str) -> AdEcpmRecord:
|
||||
return AdEcpmRecord(
|
||||
user_id=1, ad_type="draw", feed_scene=scene, ad_session_id=session_id,
|
||||
ecpm_raw=ecpm_raw, trace_id=trace_id, report_date="2020-01-02",
|
||||
created_at=datetime(2020, 1, 2, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
def test_coupon_data_report_includes_ad_revenue() -> None:
|
||||
"""领券看板明细行带本次广告收益;200+300 分 → 0.005 元。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add(CouponSession(
|
||||
trace_id="rev-cp-1", device_id="d1", status="completed", app_env="prod",
|
||||
platforms=["meituan-waimai"], platform_success=["meituan-waimai"],
|
||||
started_at=datetime(2020, 1, 2, tzinfo=UTC), started_date=date(2020, 1, 2),
|
||||
))
|
||||
db.add_all([
|
||||
_ecpm("rev-cp-1", "200", "cp-sess-a", "coupon"),
|
||||
_ecpm("rev-cp-1", "300", "cp-sess-b", "coupon"),
|
||||
])
|
||||
db.flush()
|
||||
res = coupon_data_report(db, date_from="2020-01-02", date_to="2020-01-02", app_env="prod")
|
||||
row = next(r for r in res["items"] if r["trace_id"] == "rev-cp-1")
|
||||
assert row["ad_revenue_yuan"] == 0.005
|
||||
finally:
|
||||
db.rollback()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_comparison_list_includes_ad_revenue() -> None:
|
||||
"""比价记录列表项带本次广告收益;200 分 → 0.002 元。
|
||||
|
||||
用独有 user_id 过滤,确保本行必落在第一页(避免共享测试库里同 user 记录多、分页把它挤掉)。
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add(ComparisonRecord(
|
||||
trace_id="rev-cmp-1", user_id=987654, status="success", business_type="food",
|
||||
created_at=datetime(2020, 1, 2, tzinfo=UTC),
|
||||
))
|
||||
db.add(_ecpm("rev-cmp-1", "200", "cmp-sess-a", "comparison"))
|
||||
db.commit()
|
||||
items, _next, _total = queries.list_comparison_records(db, user_id=987654)
|
||||
row = next(it for it in items if it.trace_id == "rev-cmp-1")
|
||||
assert row.ad_revenue_yuan == 0.002
|
||||
finally:
|
||||
db.execute(delete(AdEcpmRecord).where(AdEcpmRecord.trace_id.in_(["rev-cmp-1"])))
|
||||
db.execute(delete(ComparisonRecord).where(ComparisonRecord.trace_id == "rev-cmp-1"))
|
||||
db.commit()
|
||||
db.close()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
|
||||
Run: `pytest tests/test_board_ad_revenue.py -q`
|
||||
Expected: FAIL — `coupon_data_report` 明细行无 `ad_revenue_yuan` 键(KeyError);`ComparisonRecord` 无 `ad_revenue_yuan`(AttributeError)。
|
||||
|
||||
- [ ] **Step 3: 领券 schema 加字段**
|
||||
|
||||
在 `app/admin/schemas/coupon_data.py` 的 `CouponDataRow` 里,`trace_url` 字段**之后**加:
|
||||
|
||||
```python
|
||||
ad_revenue_yuan: float = Field(
|
||||
0.0, description="本次领券看的信息流广告预估收益(元);按 trace_id 聚合 ad_ecpm_record"
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 领券 repo 逐页补收益**
|
||||
|
||||
在 `app/admin/repositories/coupon_data.py`:
|
||||
|
||||
(a) import 区(现有 `from app.models.user import User` 附近)加:
|
||||
|
||||
```python
|
||||
from app.repositories import ad_ecpm as crud_ecpm
|
||||
```
|
||||
|
||||
(b) `_session_to_row` 签名改为(加末位参数):
|
||||
|
||||
```python
|
||||
def _session_to_row(r, phone: str | None = None, nickname: str | None = None, ad_revenue_yuan: float = 0.0) -> dict:
|
||||
```
|
||||
|
||||
并在其返回的 dict 里,`"trace_url": r.trace_url,` 之后加一行:
|
||||
|
||||
```python
|
||||
"ad_revenue_yuan": ad_revenue_yuan,
|
||||
```
|
||||
|
||||
(c) 在 `coupon_data_report` 里,找到构造明细的这段:
|
||||
|
||||
```python
|
||||
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))
|
||||
```
|
||||
|
||||
替换为(新增 `rev_map` + 传入):
|
||||
|
||||
```python
|
||||
rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in page])
|
||||
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, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0)))
|
||||
```
|
||||
|
||||
> `coupon_user_records`(手机号抽屉)仍走 `_session_to_row(r)`,`ad_revenue_yuan` 取默认 0.0——抽屉不展示收益列,无需补;字段有默认值故 schema 校验不受影响。
|
||||
|
||||
- [ ] **Step 5: 跑领券用例确认通过**
|
||||
|
||||
Run: `pytest tests/test_board_ad_revenue.py::test_coupon_data_report_includes_ad_revenue -q`
|
||||
Expected: PASS。
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
git add app/admin/schemas/coupon_data.py app/admin/repositories/coupon_data.py tests/test_board_ad_revenue.py
|
||||
git commit -m "feat(admin-coupon-data): per-session ad revenue column"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task B2: 比价记录看板逐行补 `ad_revenue_yuan`
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/admin/schemas/comparison.py`
|
||||
- Modify: `app/admin/repositories/queries.py`
|
||||
|
||||
- [ ] **Step 1: 比价 schema 加字段**
|
||||
|
||||
在 `app/admin/schemas/comparison.py` 的 `AdminComparisonListItem` 里,`created_at: datetime` **之前**加:
|
||||
|
||||
```python
|
||||
ad_revenue_yuan: float = 0.0 # 本次比价看的信息流广告预估收益(元),queries 瞬态挂 ORM 实例上
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 比价 repo 逐页补收益**
|
||||
|
||||
在 `app/admin/repositories/queries.py`:
|
||||
|
||||
(a) import 区加:
|
||||
|
||||
```python
|
||||
from app.repositories import ad_ecpm
|
||||
```
|
||||
|
||||
(b) 在 `list_comparison_records` 里,找到:
|
||||
|
||||
```python
|
||||
_attach_user_info(db, items)
|
||||
return items, next_cursor, total
|
||||
```
|
||||
|
||||
替换为:
|
||||
|
||||
```python
|
||||
_attach_user_info(db, items)
|
||||
# 「本次比价看广告的预估收益」:按本页 trace_id 一次性聚合(同 _attach_user_info 逐页范式)。
|
||||
# ad_revenue_yuan 非 ORM 列,仅瞬态挂实例上供 AdminComparisonListItem(from_attributes)读出。
|
||||
rev = ad_ecpm.revenue_yuan_by_trace(db, [it.trace_id for it in items])
|
||||
for it in items:
|
||||
it.ad_revenue_yuan = rev.get(it.trace_id, 0.0)
|
||||
return items, next_cursor, total
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 跑比价用例确认通过**
|
||||
|
||||
Run: `pytest tests/test_board_ad_revenue.py::test_comparison_list_includes_ad_revenue -q`
|
||||
Expected: PASS。
|
||||
|
||||
- [ ] **Step 4: 跑全量后端测试(确认无回归)**
|
||||
|
||||
Run: `pytest -q`
|
||||
Expected: 全绿(新增用例通过,原有用例不受影响)。
|
||||
|
||||
- [ ] **Step 5: Lint**
|
||||
|
||||
Run: `ruff check app/ tests/`
|
||||
Expected: 无新增告警。
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
git add app/admin/schemas/comparison.py app/admin/repositories/queries.py
|
||||
git commit -m "feat(admin-comparison): per-comparison ad revenue column"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase C — 客户端上报带 trace_id(Android)
|
||||
|
||||
> 三处都在 `E:\project\shaguabijia-app-android`。改动极小:eCPM 上报点的 trace_id 已在作用域内(比价是 `showAd(traceId)` 参数,领券是 `sessionTraceId` 类字段),只是当前没往上带。发奖(`reportFeedReward`)已在带 traceId,可作参照。
|
||||
|
||||
### Task C1: `ApiClient.reportAdImpression` 加 `traceId` 参数
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/src/main/java/com/jishisongfu/shaguabijia/agent/network/ApiClient.kt`
|
||||
|
||||
- [ ] **Step 1: 加参数**
|
||||
|
||||
找到 `reportAdImpression` 的参数列表,末尾 `feedScene: String? = null,` 之后加一行:
|
||||
|
||||
```kotlin
|
||||
traceId: String? = null,
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 写进 payload**
|
||||
|
||||
同函数内,找到:
|
||||
|
||||
```kotlin
|
||||
if (!feedScene.isNullOrBlank()) payload.put("feed_scene", feedScene)
|
||||
```
|
||||
|
||||
在其**下方**加一行:
|
||||
|
||||
```kotlin
|
||||
if (!traceId.isNullOrBlank()) payload.put("trace_id", traceId)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add app/src/main/java/com/jishisongfu/shaguabijia/agent/network/ApiClient.kt
|
||||
git commit -m "feat(ad-report): reportAdImpression carries trace_id"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task C2: 比价展示上报带 traceId
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/src/main/java/com/jishisongfu/shaguabijia/agent/service/ad/CompareAdController.kt`
|
||||
|
||||
- [ ] **Step 1: 传 traceId**
|
||||
|
||||
在 `showAd(traceId: String)` 内的 `onAdImpression` 回调里,找到 `apiClient.reportAdImpression(` 调用,其中 `feedScene = "comparison",` 之后加一行(`traceId` 即 `showAd` 的入参):
|
||||
|
||||
```kotlin
|
||||
traceId = traceId, // 本场比价 trace → 展示收益归属到该次比价
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add app/src/main/java/com/jishisongfu/shaguabijia/agent/service/ad/CompareAdController.kt
|
||||
git commit -m "feat(compare-ad): report comparison impression with trace_id"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task C3: 领券展示上报带 traceId
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/src/main/java/com/jishisongfu/shaguabijia/service/CouponForegroundService.kt`
|
||||
|
||||
- [ ] **Step 1: 传 traceId**
|
||||
|
||||
在 `onAdImpression` 回调里,找到 `apiClient.reportAdImpression(` 调用,其中 `feedScene = "coupon",` 之后加一行(`sessionTraceId` 为本类字段,整场领券不变):
|
||||
|
||||
```kotlin
|
||||
traceId = sessionTraceId, // 本场领券 trace → 展示收益归属到该次领券
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 编译验证**
|
||||
|
||||
Run(在 `E:\project\shaguabijia-app-android`): `./gradlew :app:compileDebugKotlin`
|
||||
Expected: BUILD SUCCESSFUL。
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add app/src/main/java/com/jishisongfu/shaguabijia/service/CouponForegroundService.kt
|
||||
git commit -m "feat(coupon-ad): report coupon impression with trace_id"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase D — admin 前端加「广告收益」列
|
||||
|
||||
> 两个页面都在 `e:\project\shaguabijia-admin-web`。收益值单位是**元**(小数,单次很小如 ¥0.0050),用 `.toFixed(4)` 展示。
|
||||
|
||||
### Task D1: 比价记录页加列
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/types.ts`
|
||||
- Modify: `src/app/(main)/comparison-records/page.tsx`
|
||||
|
||||
- [ ] **Step 1: 类型加字段**
|
||||
|
||||
在 `src/lib/types.ts` 的 `ComparisonRecordListItem` 接口里,`created_at: string;` **之前**加:
|
||||
|
||||
```typescript
|
||||
ad_revenue_yuan: number; // 本次比价看的信息流广告预估收益(元)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 表格加列**
|
||||
|
||||
在 `src/app/(main)/comparison-records/page.tsx` 的 `columns` 数组里,找到「省」这一列(以 `title: '省',` 开头的对象),在其**之后**插入一列:
|
||||
|
||||
```tsx
|
||||
{
|
||||
title: '广告收益',
|
||||
key: 'ad_revenue',
|
||||
width: 96,
|
||||
align: 'right',
|
||||
render: (_, r) =>
|
||||
r.ad_revenue_yuan > 0 ? (
|
||||
<span style={{ color: '#3f8600' }}>¥{r.ad_revenue_yuan.toFixed(4)}</span>
|
||||
) : (
|
||||
<span style={{ color: '#ccc' }}>-</span>
|
||||
),
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 加宽横向滚动**
|
||||
|
||||
同文件找到比价记录主表的 `scroll={{ x: 1820 }}`,改为:
|
||||
|
||||
```tsx
|
||||
scroll={{ x: 1920 }}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add src/lib/types.ts "src/app/(main)/comparison-records/page.tsx"
|
||||
git commit -m "feat(admin-web): ad revenue column in comparison records table"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task D2: 领券数据页加列
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/(main)/coupon-data/page.tsx`
|
||||
|
||||
- [ ] **Step 1: 接口加字段**
|
||||
|
||||
在 `src/app/(main)/coupon-data/page.tsx` 的 `interface CouponDataRow` 里,`trace_url: string | null;` **之后**加:
|
||||
|
||||
```typescript
|
||||
ad_revenue_yuan: number; // 本次领券看的信息流广告预估收益(元)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 加金额格式化辅助**
|
||||
|
||||
在 `fmtPct` 定义(以 `const fmtPct =` 开头)之后加:
|
||||
|
||||
```typescript
|
||||
// 元(小数)→ "¥0.0050"(空/≤0 显示 -)。单次广告收益很小,保留 4 位。
|
||||
const fmtYuan = (v: number | null | undefined): string =>
|
||||
v == null || v <= 0 ? '-' : `¥${v.toFixed(4)}`;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 主表加列**
|
||||
|
||||
找到主表 `columns`(`CouponDataPage` 组件内的 `const columns: ColumnsType<CouponDataRow> = [`)。在「耗时」列(`dataIndex: 'elapsed_ms'` 的对象)**之后**插入:
|
||||
|
||||
```tsx
|
||||
{
|
||||
title: '广告收益',
|
||||
dataIndex: 'ad_revenue_yuan',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (v: number) => fmtYuan(v),
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 加宽横向滚动**
|
||||
|
||||
找到主表 `scroll={{ x: 1450 }}`,改为:
|
||||
|
||||
```tsx
|
||||
scroll={{ x: 1560 }}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 前端类型检查 / 构建**
|
||||
|
||||
Run(在 `e:\project\shaguabijia-admin-web`): `npm run build`
|
||||
Expected: 构建成功、无 TS 类型错误。
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
git add "src/app/(main)/coupon-data/page.tsx"
|
||||
git commit -m "feat(admin-web): ad revenue column in coupon data table"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 端到端验证(全部任务完成后)
|
||||
|
||||
- [ ] **后端**:`pytest -q` 全绿;`alembic heads` 只有 `ad_ecpm_trace_id` 单 head。
|
||||
- [ ] **客户端**:装 debug 包,跑一次比价 + 一次领券(要有信息流广告展示);后端库 `ad_ecpm_record` 出现带 `trace_id`、`feed_scene in (comparison, coupon)` 的记录。
|
||||
- [ ] **看板**:admin 打开「比价记录」「领券数据」两页,新「广告收益」列对刚才那两次显示 > ¥0 的金额;旧数据(无 trace_id 上报)显示 `-`。
|
||||
- [ ] **口径核对**:任取一行,手动核对 `ad_ecpm_record` 中该 `trace_id` 的各条 `ecpm_raw`,按 `Σ min(ecpm/100, 500)/1000` 算出的值与页面一致。
|
||||
|
||||
---
|
||||
|
||||
## 回滚
|
||||
|
||||
- 前端/客户端:回退对应 commit 即可(纯展示/上报,无副作用)。
|
||||
- 后端:`alembic downgrade -1` 撤 `ad_ecpm_trace_id`(会拆回两个 head——与本计划实施前状态一致);看板收益列在无 trace_id 列时会因查询报错,故 downgrade 迁移前需先回退 Phase A/B 的 commit。正常情况不需回滚。
|
||||
+102
-97
@@ -3,7 +3,7 @@
|
||||
> 域名:`app-api.shaguabijia.com`(HTTPS,nginx 反代)
|
||||
> 仓库:`shaguabijia-app-server`
|
||||
> 接口协议详见 [`docs/api/`](./api/)(索引 + 一接口一文件)
|
||||
> 最后更新:2026-06-23(§7 数据模型改为指向 OVERVIEW、§5 美团 CPS 对齐多 tab feed/4 接口、§6 厘清 coupon/step 有写库副作用 ≠ 纯透传壳、§6.5 补 `/internal/app-version`、Alembic 迁移数更新)
|
||||
> 最后更新:2026-07-09(§1/§6.2 比价透传改「软鉴权 + trace_id 签发 + harvest 落库」不再是纯透传;§3 目录树补全到当前 22 个 v1 路由 + core 三 worker + utils/geo;§1 补 邀请奖励金/埋点/领券看板/设备存活;§5 美团 feed/top-sales 按城市过滤 #116;§7 45 张表、98 迁移;§8 部署对齐 ecs1 git-clone + systemd 定时器;§10 刷新。上一次 2026-06-23)
|
||||
|
||||
---
|
||||
|
||||
@@ -13,17 +13,23 @@
|
||||
|
||||
| 能力 | 说明 |
|
||||
|---|---|
|
||||
| **账号与登录** | 极光一键登录 + 短信验证码登录(mock)→ 签发 JWT |
|
||||
| **账号与登录** | 极光一键登录 + 短信验证码登录(SMS_MOCK 切换)→ 签发 JWT;新手引导标记(设备+账号,含用户自助重置 #114) |
|
||||
| **用户资料** | 昵称 / 头像(上传图片含魔数嗅探) / 注销账号(软删除+匿名化) |
|
||||
| **美团 CPS 选品** | 透传美团联盟优惠券(外卖/到店)、点击换取推广链接(分佣)、未配凭证时降级返空 |
|
||||
| **领券透传** | `/coupon/step` 透传到 pricebot-backend(一键领券核心,MVP 不鉴权,前端已接通) |
|
||||
| **外卖比价透传** | `/intent/recognize` + `/price/step` 透传到 pricebot-backend(food MVP,MVP 不鉴权) |
|
||||
| **金币 / 现金钱包** | 金币账户/流水/兑换/微信绑定/提现单 11 端点 |
|
||||
| **美团 CPS 选品** | feed 多 tab(rec 离线库 / distance 实时)+ 销量榜,**按设备坐标离线反查城市、只出同城券**(#116);点击换推广链接(分佣);未配凭证降级返空 |
|
||||
| **领券透传** | `/coupon/step` 透传到 pricebot-backend(一键领券核心,不鉴权,前端已接通)+ best-effort 写领券三表;`/coupon/session` 领券任务流水(admin 看板,#99) |
|
||||
| **外卖比价透传 + 落库** | `/intent/*` + `/price/step` + `/trace/finalize`、`/trace/epilogue` 透传 pricebot,**软鉴权 + 首帧签发 trace_id + harvest 三段式落 `comparison_record`**(2026-07 起,见 §6.2) |
|
||||
| **金币 / 现金钱包** | 金币账户/流水/兑换/微信绑定/提现单;**邀请奖励金独立账本**(#82,与现金物理隔离,`source` 分账提现 #121) |
|
||||
| **好友邀请** | 邀请码/落地页指纹归因;发奖口径=好友**比价并下单**(#113,发邀请奖励金) |
|
||||
| **签到 + 任务 + 省钱战绩** | 福利模块 |
|
||||
| **看广告发奖** | 穿山甲 GroMore 激励视频 S2S 回调(SHA256 验签)+ 4 态 CTA 冷却 |
|
||||
| **帮助与反馈** | 用户提交反馈(含截图)+ 静态 `/media` 服务 |
|
||||
| **看广告发奖** | 穿山甲 GroMore 激励视频 S2S 回调(SHA256 验签)+ 信息流/Draw 结算(ad_type/feed_scene 分场景)+ 穿山甲后台收益日表拉取(#92) |
|
||||
| **帮助与反馈** | 反馈工单(来源/场景/端环境采集 + admin 采纳发币/拒绝/运营回复,#94/#105)+ 静态 `/media` 服务 |
|
||||
| **埋点** | `/analytics/events` 批量上报落 `analytics_event`(#83) |
|
||||
| **设备存活监控** | 无障碍心跳 + 掉线检出(worker,超时 1h #107)+ 召回 ack;admin 存活看板(#80) |
|
||||
| **平台配置 / OTA** | `/platform/*`:门面统计、feature flags、广告配置下发、App 版本检查更新(全不鉴权) |
|
||||
| **CPS 群发联盟** | `/c/{code}` 短链落地 + 微信授权 + 联盟对账(美团+京东 #90),运营台在 admin |
|
||||
| **运营后台 admin** | 独立子应用(8771,独立 JWT):用户/钱包/提现审核/反馈/大盘/收益报表/领券看板/RBAC 自定义角色(#117/#126)等,全量端点见 [api/README](./api/README.md) Admin 段 |
|
||||
|
||||
**本服务自身没有"比价"实现**——比价/领券业务的核心都在 pricebot-backend(另一个 repo),本服务对 step 类接口做"透传壳"。无爬虫、无 LLM,但**有钱包/福利等业务模型**(数据模型从早期 1 张 `user` 表已扩展到 **40 张业务表**,见 §7 / [database/OVERVIEW.md](./database/OVERVIEW.md))。
|
||||
**"比价/领券"的执行核心在 pricebot-backend**(另一个 repo),本服务对 step 类接口做透传;但 2026-07 起比价透传**不再是纯壳**——app-server 直接负责比价记录落库(§6.2)。无爬虫、无 LLM,业务模型已扩展到 **45 张业务表**(见 §7 / [database/OVERVIEW.md](./database/OVERVIEW.md))。仓库根另有 `h5/`(mine 我的页 + shared bridge/api,#89,H5 化的我的页静态资源)。
|
||||
|
||||
---
|
||||
|
||||
@@ -52,81 +58,56 @@
|
||||
|
||||
```
|
||||
app/
|
||||
├── main.py # FastAPI 入口:注册全部 router、CORS、/health、lifespan
|
||||
├── main.py # FastAPI 入口:注册 21 个 v1 router + 4 个 internal router、CORS、
|
||||
│ # lifespan(预热 pricebot client + 离线地理库,启 3 个后台 worker)、
|
||||
│ # /media 静态服务 + /media/shaguabijia.apk 官网直链(强制下载头)
|
||||
├── api/
|
||||
│ ├── deps.py # 共享依赖:get_current_user(鉴权)、get_db(注入 session)
|
||||
│ └── v1/ # 接口层(薄):解析请求 → 调 repositories/integration → 组装响应 + HTTP 错误码
|
||||
│ ├── auth.py # 登录 6 端点(极光一键登录 / 短信 send+login / refresh / me / logout)
|
||||
│ ├── user.py # 用户资料 3 端点(改昵称 / 上传头像 / 注销账号)
|
||||
│ ├── feedback.py # 帮助与反馈 1 端点(提交反馈含截图)
|
||||
│ ├── coupon.py # 领券透传 /coupon/step(转发 pricebot,MVP 不鉴权)
|
||||
│ ├── compare.py # 外卖比价透传 /intent/recognize + /price/step(转发 pricebot,MVP 不鉴权)
|
||||
│ ├── compare_record.py# 比价记录 3 端点(上报 /compare/record + 列表 /compare/records + 详情;鉴权,区别于上面透传)
|
||||
│ ├── meituan.py # 美团 3 端点 + feed 拼接(_interleave / _TOPIC_ROUNDS),未配 MT_CPS 凭证降级返空
|
||||
│ ├── wallet.py # 钱包/提现 11 端点(余额/流水/兑换/绑微信/提现/查单)
|
||||
│ ├── signin.py # 签到 2 端点(状态 / 执行签到)
|
||||
│ ├── tasks.py # 一次性任务 2 端点(列表 / 领取)
|
||||
│ ├── savings.py # 省钱 3 端点(汇总 / 战绩 / 明细)
|
||||
│ └── ad.py # 看广告发奖 3 端点(穿山甲 S2S 回调 / 进度+本轮冷却 / 联调发奖)
|
||||
├── schemas/ # Pydantic:API 收发的数据契约(与客户端对齐字段看这里)
|
||||
│ ├── auth.py
|
||||
│ ├── user.py # 改昵称请求 + OkResponse
|
||||
│ ├── feedback.py # 反馈出参(请求是 multipart,在 router 直接校验)
|
||||
│ ├── meituan.py
|
||||
│ ├── welfare.py # 钱包/签到/任务/省钱 收发模型
|
||||
│ ├── compare_record.py # 比价记录上报/列表/详情 收发模型(字段对齐 pricebot calibration + done.params)
|
||||
│ └── ad.py # 看广告发奖收发模型
|
||||
├── integrations/ # 外部服务/SDK 客户端(重逻辑:签名/加解密/外部 HTTP)
|
||||
│ ├── jiguang.py # 极光 REST 验 token + RSA 解密(多 padding 试错)
|
||||
│ ├── meituan.py # 美团 CPS 网关签名 + query_coupon / get_referral_link
|
||||
│ ├── sms.py # 短信验证码(mock,进程内存冷却表)
|
||||
│ ├── pangle.py # 穿山甲激励视频发奖回调验签(SHA256,2026-05 从 core 移入)
|
||||
│ └── wxpay.py # 微信支付 V3 商家转账(提现)+ code 换 openid(2026-05 从 core 移入)
|
||||
├── core/ # 基础设施(无外部业务集成)
|
||||
│ ├── config.py # pydantic-settings
|
||||
│ ├── security.py # JWT 签发/校验
|
||||
│ ├── ratelimit.py # 同 IP 滑动窗口限流依赖
|
||||
│ ├── rewards.py # 发奖/兑换/提现额度等业务常量与换算(2026-05 加 VIDEO_ROUND_REQUIRED_COUNT / VIDEO_ROUND_COOLDOWN_SECONDS)
|
||||
│ ├── media.py # 用户上传文件(头像/反馈截图)落盘 + 魔数嗅探 + 随机文件名
|
||||
│ └── logging.py
|
||||
├── repositories/ # 数据访问 + 事务(早期叫 crud,2026-05 统一并入此目录)
|
||||
│ ├── user.py # get_user_by_id / by_phone / upsert_for_login / update_nickname / set_avatar_url / soft_delete_account
|
||||
│ ├── feedback.py # 提交反馈写库
|
||||
│ ├── wallet.py # 账户/流水/兑换/提现单(调 integrations/wxpay)
|
||||
│ ├── signin.py # 签到记录 / 连续天数 / 档位
|
||||
│ ├── task.py # 一次性任务领取
|
||||
│ ├── savings.py # 省钱汇总 / 战绩 / 明细
|
||||
│ ├── comparison.py # 比价记录 upsert(user_id+trace_id 幂等)+ best/saved/status 派生 + 分页
|
||||
│ └── ad_reward.py # 看广告发奖(按 trans_id 幂等 + 每日上限 + 本轮冷却派生)
|
||||
├── models/ # ORM 表结构
|
||||
│ ├── user.py # user(含微信 openid/nickname/avatar)
|
||||
│ ├── feedback.py # 用户反馈(content/contact/images JSON 列/status)
|
||||
│ ├── wallet.py # 金币账户 / 金币流水 / 现金流水 / 提现单
|
||||
│ ├── signin.py # 签到记录
|
||||
│ ├── task.py # 任务领取记录
|
||||
│ ├── savings.py # 省钱明细 / 店铺菜品 / dishes(PG 上 JSONB)
|
||||
│ ├── comparison.py # 比价记录(完整明细;含 4 个 JSON(B) 列 + raw_payload;独立于 savings)
|
||||
│ └── ad_reward.py # 看广告发奖记录
|
||||
└── db/
|
||||
├── base.py # DeclarativeBase
|
||||
└── session.py # engine + get_db(非 SQLite 时启 pool: size=10/overflow=20/recycle=3600)
|
||||
│ ├── deps.py # 共享依赖:get_current_user / OptionalUser(软鉴权) / get_db
|
||||
│ ├── internal/ # server→server 内部端点(X-Internal-Secret):app_version / launch_confirm / price / store
|
||||
│ └── v1/ # 接口层(薄),21 个路由文件:
|
||||
│ ├── auth.py user.py feedback.py # 登录 / 资料+引导(含 reset #114) / 反馈工单
|
||||
│ ├── coupon.py # 领券透传 step + session 流水 + prompt/completed 频控族
|
||||
│ ├── compare.py # 比价透传 + trace_id 签发 + harvest 落库(§6.2)
|
||||
│ ├── compare_record.py compare_milestone.py # 比价记录(鉴权兜底上报/列表/详情/stats)+ 里程碑
|
||||
│ ├── meituan.py # CPS 选品 4 端点(feed 多 tab / top-sales 按城市 #116)
|
||||
│ ├── wallet.py wxpay.py # 钱包/提现(source 分账 #121)/transfer-auth 族 + 微信回调 stub
|
||||
│ ├── signin.py tasks.py savings.py # 福利
|
||||
│ ├── ad.py # 激励视频 S2S + 信息流/Draw 结算 + eCPM/noshow/watch
|
||||
│ ├── invite.py order.py report.py # 邀请 / 支付归因(触发邀请发奖 #113) / 上报更低价
|
||||
│ ├── analytics.py device.py platform.py # 埋点 #83 / 无障碍存活 #65 / 门面+flags+ad-config+OTA
|
||||
│ └── cps_redirect.py # /c/{code} 短链落地 + 微信 OAuth(挂域名根)
|
||||
├── admin/ # 运营后台独立子应用(8771,独立 JWT;app.main 不 import 它)
|
||||
│ ├── main.py deps.py security.py permissions.py # 入口 / 鉴权链 / RBAC 页面目录(#117)
|
||||
│ └── routers/ # 23 个路由:users wallet withdraw feedback(+qr) dashboard comparison
|
||||
│ # coupon_data device_liveness event_logs price_report onboarding
|
||||
│ # ops_marquee_seed ops_stat_config ad_audit ad_revenue ad_config
|
||||
│ # config admins roles(#126) audit auth cps
|
||||
├── schemas/ # Pydantic 契约(17 个文件,与客户端对齐字段看这里)
|
||||
├── integrations/ # 外部 SDK(重逻辑):jiguang / meituan(S-Ca 签名) / sms / pangle / wxpay
|
||||
├── core/ # 基础设施:config(+config_schema 运营可配项定义) / security / ratelimit /
|
||||
│ │ # rewards / media / logging / ad_cooldown / test_account(测试号免验证码 #69)
|
||||
│ ├── pricebot_router.py # pricebot 多实例一致性 hash(ketama 1000 虚节点,按 trace_id 亲和)
|
||||
│ ├── pricebot_client.py # 共享 httpx AsyncClient 单例(#87:免每请求重建 SSL 上下文、绕进程代理)
|
||||
│ ├── withdraw_reconcile_worker.py # 提现对账 worker(lifespan 启动)
|
||||
│ ├── heartbeat_monitor_worker.py # 无障碍心跳掉线检出 worker(#65,超时 1h #107)
|
||||
│ └── daily_exchange_worker.py # 金币自动兑换 worker
|
||||
├── utils/ # geo.py(离线经纬度→城市反查,~2.5M 行 CSV+KDTree,启动预热)
|
||||
│ # + meituan_city.py(城市→美团 city_id,#116)
|
||||
├── repositories/ # 数据访问 + 事务(28 个文件;comparison.py 含 harvest_running/done/abort 三段)
|
||||
├── models/ # ORM 表结构(34 个文件,45 张业务表,见 database/OVERVIEW.md)
|
||||
└── db/ # DeclarativeBase + engine/get_db(非 SQLite 启 pool 10+20)
|
||||
|
||||
alembic/ # 数据库迁移(versions/ 11+ 个迁移含 feedback_table / convert_dishes_jsonb / merge 等)
|
||||
deploy/ # systemd(.service) + nginx(.conf)
|
||||
secrets/ # 极光 RSA 私钥 / 微信支付证书(不入 git,仅 .gitkeep 占位)
|
||||
scripts/
|
||||
├── init_postgres.py # 一键 PG 初始化:建用户 + 建库 + 写 .env + 跑迁移(2026-05 新增,见已知 bug §10)
|
||||
├── migrate.sh # 单独跑 alembic upgrade head(部署/CI 用)
|
||||
├── reset_signin.py # 重置今日签到
|
||||
├── reset_welfare.py # 重置福利数据
|
||||
├── reconcile_withdraws.py # 提现对账
|
||||
└── sim_pangle_callback.py # 模拟穿山甲回调
|
||||
tests/ # pytest(auth / health / welfare / withdraw / ad_reward / coupon_proxy / compare_proxy)
|
||||
run.sh # 本地启动脚本(自动先跑迁移再起服务)
|
||||
docs/api/ # API 接口文档(索引 README + 一接口一文件)
|
||||
docs/integrations/ # 集成层实现文档(SDK 签名/加解密/协议细节)
|
||||
docs/database/数据库迁移.md # Alembic 迁移指南(如何建表/升级/新增迁移)
|
||||
docs/database/postgres-migration.md # SQLite → PostgreSQL 切换指南(配套 scripts/init_postgres.py)
|
||||
h5/ # H5 静态页(#89):mine 我的页 + shared bridge/api
|
||||
alembic/ # 数据库迁移(98 个,含 14+ merge;单 head,见 §7)
|
||||
deploy/ # systemd:app-server + admin 两服务;定时器 meituan-etl(选品 ETL)/
|
||||
│ # pangle-revenue(穿山甲收益,每天 10:30 #100)/daily-exchange;nginx 配置
|
||||
secrets/ # 极光 RSA 私钥 / 微信支付证书(不入 git)
|
||||
scripts/ # init_postgres / migrate.sh / create_admin / 美团券 ETL(pull_meituan_coupons +
|
||||
│ # load_meituan_coupon_tsv) / sync_pangle_revenue(#92) / publish_apk /
|
||||
│ # reconcile_withdraws / reset_* / sim_pangle_callback / seed_mock_*(造数)
|
||||
tests/ # pytest(外部集成全 monkeypatch,不打真 HTTP)
|
||||
run.sh # 本地启动(钉定 .venv 解释器 #75,先迁移再起服务)
|
||||
docs/api/ docs/database/ docs/integrations/ docs/guides/ # 文档(各自带索引)
|
||||
```
|
||||
|
||||
> **命名说明**:`api/v1/` 的 `v1` 用于 URL 版本化(移动端无法强制即时升级,需新旧版本并存能力);`integrations` 装外部 SDK 集成、`repositories` 装数据访问、`core` 装基础设施,三者分离。**数据访问层统一在 `repositories/`**(早期叫 `crud/`,2026-05 已整体并入,`crud/` 不再存在)。`coupon.py` 是领券透传,勿与 `meituan.py` 里的 `coupons`(券列表)混淆。
|
||||
@@ -190,15 +171,15 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
|
||||
|
||||
### 5.2 对外四个接口
|
||||
|
||||
> 接口级入参/出参/各 tab 行为详见 [api/meituan-feed.md](./api/meituan-feed.md) 等,本节只讲后端形态。
|
||||
> 接口级入参/出参/各 tab 行为详见 [api/meituan/meituan-feed.md](./api/meituan/meituan-feed.md) 等,本节只讲后端形态。
|
||||
|
||||
- `coupons`:对外的搜索/榜单接口(底层 `query_coupon`),**客户端暂未接入**。
|
||||
- `feed`:首页推荐流,**已是多 tab**(入参 `tab`):
|
||||
- `rec` 智能推荐:走**离线库 `meituan_coupon`**(筛佣金率≥3%、`DISTINCT ON` 去重、按销量降序分页),**纯库查询、不打美团、不依赖 MT 凭证**(实测同城热销中位佣金 ~0.8%,实时筛≥3% 每页剩 0–1 条又撞 402,故从库出)。不显示距离。
|
||||
- `rec` 智能推荐:走**离线库 `meituan_coupon`**(筛佣金率≥3%、`DISTINCT ON` 去重、按销量降序分页),**纯库查询、不打美团、不依赖 MT 凭证**(实测同城热销中位佣金 ~0.8%,实时筛≥3% 每页剩 0–1 条又撞 402,故从库出)。不显示距离。**#116 起按城市过滤**:设备经纬度经 `utils/geo`(离线反查,启动预热)+ `meituan_city` 映射成美团 `city_id`,只出同城券;拿不到坐标/城市 → 返空 + `status=degraded`。
|
||||
- `distance` 距离最近:实时拉外卖+到店两路,按用户坐标由近及远。
|
||||
- 默认(空 tab):旧的逐轮分页混合 feed(2 外卖 + 1 到店交叉,写死 3 页爆款/今日必推/精选+限时,第 4 页返空),**仅老客户端兼容**。
|
||||
- 任何失败场景返 `200` + 空 `items` + `status=degraded`(不抛 5xx)。
|
||||
- `top-sales`:独立的销量榜接口,离线库 `meituan_coupon` 按销量降序 + 跨源去重,**不实时打美团**。
|
||||
- `top-sales`:独立的销量榜接口,离线库 `meituan_coupon` 按销量降序 + 跨源去重 + **同城过滤**(#116,同 rec 的城市反查),**不实时打美团**。
|
||||
- `referral-link`:换推广链接,客户端取 `link_map["3"]`(deeplink)优先跳美团 App。
|
||||
- `query_coupon`:底层取数(被 `coupons`/`distance` feed 共用),不直接对外。
|
||||
|
||||
@@ -212,7 +193,7 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
|
||||
|
||||
## 6. 领券透传(coupon/step)
|
||||
|
||||
产品"一键领券"的核心接入点,**真正的领券逻辑不在本服务**——在另一个 repo `pricebot-backend`(GoalEngine + 事件驱动)。但 `coupon/step` **不是纯透传壳**:它在转发 pricebot 之余,还**best-effort 写库**(纯透传壳是 `compare.py` 的 `intent/recognize`、`price/step`,它们只转发不写库)。
|
||||
产品"一键领券"的核心接入点,**真正的领券逻辑不在本服务**——在另一个 repo `pricebot-backend`(GoalEngine + 事件驱动)。但 `coupon/step` **不是纯透传壳**:它在转发 pricebot 之余,还**best-effort 写库**。(比价透传 `compare.py` 2026-07 起同样带落库副作用,见 §6.2——现在全站已没有"只转发不写库"的透传端点,只有 `trace/epilogue` 例外。)
|
||||
|
||||
```
|
||||
客户端 → POST /api/v1/coupon/step (任意 JSON body,含 device_id/trace_id/step)
|
||||
@@ -231,6 +212,29 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
|
||||
- **错误**:body 非合法 JSON → 400;pricebot 不可达或返回 5xx → 502。
|
||||
- **配置**:`PRICEBOT_BASE_URL`(默认 `http://localhost:8000`)、`PRICEBOT_REQUEST_TIMEOUT_SEC`(默认 30s,因领券单帧最多 wait 6s)。
|
||||
- **现状**:**前端已接通**——首页「去领取」→ `CouponPromptDialog` → 权限检查 → 无障碍引擎 `startCouponClaim` → 循环调本接口,逐张券下发 launch/wait/done。pricebot-backend 不在本目录。
|
||||
- **配套**:`POST /coupon/session`(#99)由客户端**独立两段上报**(发起建行/收尾更新)落 `coupon_session` 流水,供 admin「领券数据」看板算发起数/完成率/中途流失/耗时——与 step 透传链路解耦。
|
||||
|
||||
---
|
||||
|
||||
## 6.2 外卖比价透传:软鉴权 + trace_id 签发 + harvest 落库(2026-07)
|
||||
|
||||
`compare.py` 的 `/intent/recognize`、`/intent/step`、`/intent/precoupon/step`、`/price/step`、`/trace/finalize`、`/trace/epilogue` 透传 pricebot 之余,**由 app-server 直接落库比价记录**(不再依赖客户端 POST /compare/record):
|
||||
|
||||
```
|
||||
客户端首帧(可不带 trace_id)
|
||||
→ compare._forward:app-server 用 uuid 签发 trace_id、注入转发 body、回填响应顶层
|
||||
→ [harvest_running] 仅 mint 那帧:按 trace_id 建 comparison_record 的 running 行
|
||||
→ 后续帧原样透传(原始 bytes 快路,不再写库)
|
||||
→ done 帧(price/step)→ [harvest_done] 写 success/failed + 派生 best_*/saved_amount_cents
|
||||
→ 用户终止/未识别 → 客户端打 /trace/finalize → [harvest_abort] 写 cancelled/failed(不降级已 success)
|
||||
→ /trace/epilogue(#112):App 结果页截图透传入 trace,纯透传、不落库
|
||||
```
|
||||
|
||||
- **软鉴权(OptionalUser)**:新客户端带 JWT → 记录绑 `user_id`;老客户端不带 → `user_id` 暂空,由其后续带 JWT 的 `POST /compare/record` 上报补齐(灰度期两条写路径按 `trace_id` reconcile)。
|
||||
- **写库全 best-effort**:`run_in_threadpool` + 独立 SessionLocal,失败只 warning、绝不连累比价返回(同 coupon.py 口径)。
|
||||
- **trace_url** 从 pricebot 响应顶层取(pricebot 每帧都带);查看权限按 `user.debug_trace_enabled` 控制。
|
||||
- **邀请发奖不在 harvest**:#113 口径 = 好友「比价并下单」,发放在 `POST /order/report`(`invite.try_reward_on_compare`,`compare_reward_granted` 幂等闸,发**邀请奖励金**入独立账本)。
|
||||
- 透传基建:`core/pricebot_router.pick_pricebot`(按 trace_id 一致性 hash 选实例)+ `core/pricebot_client`(共享 httpx 单例,#87)。
|
||||
|
||||
---
|
||||
|
||||
@@ -240,13 +244,13 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
|
||||
|
||||
**启动确认窗兜底样本**(2026-06):国产 ROM 打开别的 App 时弹"想要打开 XX"确认窗,pricebot 对没见过文案(繁体/英文/ROM 改版)的窗用 LLM 兜底放行后,把样本 POST 到 [`/internal/launch-confirm-sample`](../app/api/internal/launch_confirm.py) 落 `launch_confirm_sample` 表(host 包 + 弹窗树 + LLM plan + 设备 locale/机型),供研发定期人工沉淀回 pricebot 的规则 yaml(回到快路径)。**需 pricebot 与 app-server 两边 `.env` 配同一 `INTERNAL_API_SECRET` 才生效**(未配则 pricebot 侧跳过上报、不影响比价/领券)。
|
||||
|
||||
> 同类内部回写端点(同走 `X-Internal-Secret`)还有:`/internal/price-observation`(价格观测)、`/internal/store-mapping` + `/store-mapping/lookup` + `/store-mapping/invalidate`(跨平台店铺映射沉淀/反查/失效)等 pricebot 比价资产沉淀;以及 **`/internal/app-version`(OTA)**——**发布流程**(非 pricebot)出 APK 后写最新版本号/下载链接/sha256 落 `app_config`,客户端再 `GET /api/v1/platform/app-version` 读做检查更新。完整 6 个内部端点见 [api/internal.md](./api/internal.md)。
|
||||
> 同类内部回写端点(同走 `X-Internal-Secret`)还有:`/internal/price-observation`(价格观测)、`/internal/store-mapping` + `/store-mapping/lookup` + `/store-mapping/invalidate`(跨平台店铺映射沉淀/反查/失效)等 pricebot 比价资产沉淀;`GET /internal/launch-confirm-samples`(#91,样本列表供 pricebot `distill_launch_confirm.py` 聚合沉淀回静态规则);以及 **`/internal/app-version`(OTA)**——**发布流程**(非 pricebot)出 APK 后写最新版本号/下载链接/sha256 落 `app_config`,客户端再 `GET /api/v1/platform/app-version` 读做检查更新。完整 7 个内部端点见 [api/internal/internal.md](./api/internal/internal.md)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 数据模型
|
||||
|
||||
**完整表清单不在本文维护**(避免双份漂移)——共 **40 张业务表** + `alembic_version` 框架表,逐表字段级说明 + 跨表关系/写入路径见 **[database/OVERVIEW.md](./database/OVERVIEW.md)**(总览)与 [database/README.md](./database/README.md)(一表一文件索引)。生产 PG / 开发可回退 SQLite。
|
||||
**完整表清单不在本文维护**(避免双份漂移)——共 **45 张业务表** + `alembic_version` 框架表,逐表字段级说明 + 跨表关系/写入路径见 **[database/OVERVIEW.md](./database/OVERVIEW.md)**(总览)与 [database/README.md](./database/README.md)(一表一文件索引)。生产 PG / 开发可回退 SQLite。2026-06 下旬以来新增:`coupon_session`(#99)、`analytics_event`(#83)、`invite_cash_transaction`(#82)、`admin_role`(#117)、`ad_pangle_daily_revenue`(#92)。
|
||||
|
||||
本文只保留分层与高频维度的速查:数据访问统一在 `repositories/`,ORM 表结构在 `models/`(钱包/福利在 `wallet.py`/`signin.py`/`task.py`、比价在 `comparison.py`、领券今日状态在 `coupon_state.py`、CPS 群发在 `cps_*.py`、pricebot 内部沉淀在 `price_observation.py`/`store_mapping.py`/`launch_confirm_sample.py`、无障碍存活在 `device.py`)。下面单列最常用的 `user` 表字段。
|
||||
|
||||
@@ -265,15 +269,15 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
|
||||
|
||||
`upsert_user_for_login`:phone 存在则更新 `last_login_at`,不存在则注册(注册即登录)。
|
||||
|
||||
**Alembic 迁移**:`alembic/versions/` 当前约 **78 个迁移文件**(含 14 个合并迁移),当前单一 head `4dc2af7ebe74`(2026-06-23 合并 #65 device 链 + comparison token 列两条并行链)。多人/多分支并行改表频繁产生多 head,靠 `alembic merge` 收敛回单 head。规模随改表增长,**别背具体链**,以 `alembic history`/`alembic heads` 实时输出为准。详见 [数据库迁移.md](./database/数据库迁移.md)。
|
||||
**Alembic 迁移**:`alembic/versions/` 当前 **98 个迁移文件**(含 14+ 个合并迁移),当前单一 head `admin_user_pages_override`(2026-07-08,#126)。多人/多分支并行改表频繁产生多 head,靠 `alembic merge` 收敛回单 head。规模随改表增长,**别背具体链**,以 `alembic history`/`alembic heads` 实时输出为准。⚠️ revision id 长度 ≤32 字符(`alembic_version` 列宽,超长部署时截断报错——0.2.1 实踩)。详见 [数据库迁移.md](./database/数据库迁移.md)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 配置与部署
|
||||
|
||||
配置见 `core/config.py`(pydantic-settings 读 `.env`)。分组:环境、`DATABASE_URL`(默认 SQLite,生产应切 PG)、JWT、极光(`JG_*`)、短信(`SMS_MOCK` 默认 true)、美团(`MT_CPS_*`)、**pricebot 上游(`PRICEBOT_BASE_URL` / `PRICEBOT_REQUEST_TIMEOUT_SEC=30` / `PRICEBOT_COMPARE_TIMEOUT_SEC=60`)**、**媒体存储(`MEDIA_ROOT=./data/media` / `MEDIA_URL_PREFIX=/media`)**、CORS。
|
||||
配置见 `core/config.py`(pydantic-settings 读 `.env`)。分组:环境、`DATABASE_URL`(默认 SQLite,生产应切 PG)、JWT(+独立 `ADMIN_JWT_SECRET`)、极光(`JG_*`)、短信(`SMS_MOCK` 默认 true)、美团(`MT_CPS_*`)、**pricebot 上游(`PRICEBOT_BASE_URL` / 多实例 `PRICEBOT_INSTANCES` / `PRICEBOT_REQUEST_TIMEOUT_SEC=30` / `PRICEBOT_COMPARE_TIMEOUT_SEC=60`)**、**内部密钥(`INTERNAL_API_SECRET`,internal 族)**、穿山甲(`PANGLE_*`,含收益 API)、微信支付(`WXPAY_*`)、**媒体存储(`MEDIA_ROOT=./data/media` / `MEDIA_URL_PREFIX=/media`)**、CORS;运营可覆盖的业务常量在 `core/config_schema.py` 定义、`app_config` 表落值(admin `GET/PATCH /config`)。
|
||||
|
||||
**生产部署**:systemd `shaguabijia-app-server.service`(WorkingDirectory `/opt/shaguabijia-app-server`,`EnvironmentFile=.env`,uvicorn 监听 `127.0.0.1:8770`,`--workers 1`)+ nginx 443 反代 → 8770。**无 Docker**。**PostgreSQL 16** 同机部署。
|
||||
**生产部署(2026-06-06 起在 ecs1)**:双 systemd 服务 `shaguabijia-app-server.service`(uvicorn `127.0.0.1:8770`,`--workers 1`)+ `shaguabijia-admin.service`(8771),WorkingDirectory `/opt/shaguabijia-app-server`,`EnvironmentFile=.env`,nginx 443 反代 `app-api.shaguabijia.com` → 8770。**无 Docker**。**PostgreSQL 16** 同机部署。代码走 **git clone(main 分支)+ Gitea 只读部署密钥**,日常发布用服务器上的 `deploy` 一键命令 / `release.sh` 发车 / deploy.shaguabijia.com 发车台(不再 rsync 整目录;`secrets/` 私钥证书单独放置、不入 git)。
|
||||
|
||||
```bash
|
||||
# 首次 PG 初始化(新机器或新环境):
|
||||
@@ -281,12 +285,13 @@ ssh server "sudo apt install -y postgresql-16 && sudo systemctl enable --now pos
|
||||
ssh server "cd /opt/shaguabijia-app-server && .venv/bin/python scripts/init_postgres.py"
|
||||
# 该脚本会建业务用户 + 建库 + 写 .env 的 DATABASE_URL + 跑 alembic upgrade head
|
||||
|
||||
# 后续日常部署:
|
||||
rsync -avz --exclude='.venv' --exclude='__pycache__' --exclude='data' --exclude='secrets/*.pem' ./ server:/opt/shaguabijia-app-server/
|
||||
scp secrets/jverify_rsa_private.pem server:/opt/shaguabijia-app-server/secrets/ # 私钥单独传,不入 git
|
||||
ssh server "cd /opt/shaguabijia-app-server && .venv/bin/alembic upgrade head && systemctl restart shaguabijia-app-server"
|
||||
# 日常部署(deploy 一键命令等价动作):
|
||||
ssh server "cd /opt/shaguabijia-app-server && git pull && .venv/bin/alembic upgrade head \
|
||||
&& systemctl restart shaguabijia-app-server shaguabijia-admin"
|
||||
```
|
||||
|
||||
**systemd 定时器**(`deploy/`,各带 .md 运维手册):`meituan-etl`(美团 CPS 选品 ETL → `meituan_coupon`,rec/top-sales 数据源)、`pangle-revenue`(穿山甲 GroMore 后台收益拉取,每天 10:30,#100)、`daily-exchange`(金币自动兑换;lifespan 里另有 in-process worker,两套并存以部署材料为准)。
|
||||
|
||||
完整 PG 切换流程见 [docs/database/postgres-migration.md](./database/postgres-migration.md)。
|
||||
|
||||
**生产 checklist(均为上线必查)**:
|
||||
@@ -325,8 +330,8 @@ conda activate price # 首次:pip install -e .
|
||||
| logout 无服务端失效 | 靠客户端清 token;后续加 jti 黑名单表(注销账号也是同问题——软删后旧 token 仍能用到自然过期) |
|
||||
| 美团接口无鉴权 + sid 可覆盖 | 评估加鉴权/锁定 sid(注意首页要求未登录可见) |
|
||||
| 美团接口未配凭证降级 | 未配 `MT_CPS_APP_KEY` 时 3 端点返空(不报 502),`/feed` 跟"已配但调用失败"路径无法区分——见 [integrations/meituan](./integrations/meituan.md) |
|
||||
| 领券/比价依赖 pricebot | `coupon/step` / `intent/recognize` / `price/step` 仅透传,真正逻辑在 pricebot-backend;前端已接通领券链路,比价 food MVP 也已接通 |
|
||||
| agent 系列接口 MVP 不鉴权 | 拿不到 user_id → 无法采集"哪个用户领了/买了什么"用户级画像(商业模式核心资产)。见 [待办与技术债.md](./guides/待办与技术债.md) P1 |
|
||||
| 领券/比价依赖 pricebot | 执行核心在 pricebot-backend;比价透传 2026-07 起带 harvest 落库(§6.2),领券 `coupon/step` 带三表副作用,均非纯壳 |
|
||||
| 领券 step 仍不鉴权;比价已软鉴权 | 比价透传族改 OptionalUser:带 JWT 即绑 `user_id`(用户级画像已能落到比价记录);`coupon/step` 仍拿不到 user_id(device 维度),老客户端比价记录靠 `/compare/record` 兜底补绑。见 [待办与技术债.md](./guides/待办与技术债.md) |
|
||||
| SMS 已接极光(2026-06-03) | real 模式自定义验证码,上线只需 `SMS_MOCK=false`(复用极光凭证)。详见 [integrations/sms](./integrations/sms.md) |
|
||||
| 短信冷却存内存 | 扩 worker 前需迁移到 Redis |
|
||||
| `MEDIA_ROOT` 进程内 serve | 头像/反馈截图当前用 FastAPI StaticFiles,生产建议 nginx 直 serve 该目录 |
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""收益明细「金币记录」文案验证脚手架(2026-07 文案改版验收用)。
|
||||
|
||||
问题:客户端按 bizType 显示固定文案(路线B,强制覆盖后端 remark),但账号若没有对应
|
||||
bizType 的流水,收益明细页就空着,无从验证。本脚本往指定测试用户塞每种 bizType 各一条
|
||||
流水,让你在手机收益明细页一屏核对全部新文案;验收完 --clean 一键删除,不污染数据。
|
||||
|
||||
⚠️ 仅 dev 库用(APP_ENV=dev 时才允许 --seed/--clean)。所有测试流水 ref_id 前缀 TESTDOC,
|
||||
按前缀精确清理,不会误删真实流水。
|
||||
|
||||
用法(pricebot env 直调,见项目 CLAUDE.md):
|
||||
D:/miniconda/envs/pricebot/python.exe scripts/seed_coinhistory_labels_test.py --show
|
||||
D:/miniconda/envs/pricebot/python.exe scripts/seed_coinhistory_labels_test.py --seed
|
||||
D:/miniconda/envs/pricebot/python.exe scripts/seed_coinhistory_labels_test.py --clean
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.rewards import CN_TZ
|
||||
from datetime import datetime
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.user import User
|
||||
from app.models.wallet import CoinAccount, CoinTransaction
|
||||
|
||||
TEST_PHONE = "11111111111"
|
||||
REF_PREFIX = "TESTDOC" # 所有本脚本造的流水都带这个 ref_id 前缀,便于精确清理
|
||||
|
||||
# 每条 = (bizType, 故意写错/留空的 remark, 金币数)。
|
||||
# remark 故意填「错的」→ 若客户端仍显示新文案 = 证明路线B强制覆盖生效(无视后端 remark)。
|
||||
# task_ 一条留空 remark → 走客户端兜底映射。顺序即手机上从新到旧的展示顺序(后塞的在最上)。
|
||||
CASES: list[tuple[str, str, int]] = [
|
||||
("signin", "每日签到 第99天(旧文案,应被覆盖)", 220),
|
||||
("signin_boost", "签到膨胀 第99天", 3000),
|
||||
("reward_video", "看视频奖励金币(旧文案,应被覆盖)", 200),
|
||||
("feed_ad_reward_comparison", "", 50), # 后端新拆:比价场景 → 比价奖励
|
||||
("feed_ad_reward_coupon", "", 50), # 后端新拆:领券场景 → 领券奖励
|
||||
("feed_ad_reward", "信息流广告奖励(welfare/旧数据兜底)", 50),
|
||||
("price_report_reward", "上报更低价审核通过(旧文案,应被覆盖)", 1000),
|
||||
("feedback_reward", "意见反馈被采纳(旧文案,应被覆盖)", 10000),
|
||||
("task_enable_notification", "", 750), # remark 留空 → 客户端「打开消息提醒奖励」
|
||||
# 下两条现实中不会进金币记录(invite 发现金进邀请钱包 / compare_milestone 后端死代码不发钱),
|
||||
# 仅用于验证「杀掉好友比价奖励 + 兜底改任务奖励」:两条都应显示「任务奖励」(remark 被强制无视)。
|
||||
("invite", "好友比价奖励(旧文案,应被杀→任务奖励)", 200),
|
||||
("compare_milestone", "", 120),
|
||||
]
|
||||
|
||||
|
||||
def _client_coin_title(biz_type: str, remark: str | None) -> str:
|
||||
"""复刻 CoinHistoryViewModel.coinTitle 的最新逻辑(路线B),用于 --show 预览。
|
||||
|
||||
⚠️ 必须与客户端保持一致;客户端改了这里也要同步,否则预览会骗人。
|
||||
"""
|
||||
fixed = {
|
||||
"exchange_out": "金币兑换现金",
|
||||
"signin": "每日签到奖励",
|
||||
"signin_boost": "签到膨胀奖励",
|
||||
"reward_video": "看视频赚金币",
|
||||
"ad_reward": "看视频赚金币",
|
||||
"feed_ad_reward_comparison": "比价奖励",
|
||||
"feed_ad_reward_coupon": "领券奖励",
|
||||
"feed_ad_reward": "信息流广告奖励",
|
||||
"price_report_reward": "爆料奖励",
|
||||
"feedback_reward": "反馈奖励",
|
||||
"invite": "任务奖励", # 杀掉"好友比价奖励",归兜底
|
||||
}
|
||||
if biz_type in fixed:
|
||||
return fixed[biz_type]
|
||||
if remark:
|
||||
return remark
|
||||
if biz_type == "task_enable_notification":
|
||||
return "打开消息提醒奖励"
|
||||
return "任务奖励"
|
||||
|
||||
|
||||
def _get_user(db) -> User:
|
||||
u = db.query(User).filter(User.phone == TEST_PHONE).first()
|
||||
if not u:
|
||||
print(f"✗ 库里没有测试号 {TEST_PHONE} —— 先在手机上用这个号登录一次再跑本脚本。")
|
||||
sys.exit(1)
|
||||
return u
|
||||
|
||||
|
||||
def cmd_show() -> None:
|
||||
"""只打印:每种 bizType 经客户端映射后会显示成什么(不写库)。"""
|
||||
print("bizType 造流水后,收益明细页预期显示的文案:\n")
|
||||
print(f" {'bizType':32} {'后端remark(故意填的)':32} → 手机显示")
|
||||
print(" " + "-" * 90)
|
||||
for biz, remark, _coin in CASES:
|
||||
shown = _client_coin_title(biz, remark or None)
|
||||
rk = (remark or "(空)")
|
||||
print(f" {biz:32} {rk:32} → {shown}")
|
||||
print("\n注:remark 列是故意填的『旧/错』文案;'手机显示'若为新文案 = 强制覆盖生效。")
|
||||
print("invite / compare_milestone 已从客户端映射删除:invite 有 remark 故显示原样,")
|
||||
print("compare_milestone remark 空故落兜底『奖励』—— 两者都不再有专属新文案(符合『去掉』)。")
|
||||
|
||||
|
||||
def cmd_seed() -> None:
|
||||
if settings.APP_ENV != "dev":
|
||||
print(f"✗ 拒绝:APP_ENV={settings.APP_ENV},本脚本只在 dev 库造测试数据。")
|
||||
sys.exit(1)
|
||||
db = SessionLocal()
|
||||
u = _get_user(db)
|
||||
acc = db.query(CoinAccount).filter(CoinAccount.user_id == u.id).first()
|
||||
if acc is None:
|
||||
acc = CoinAccount(user_id=u.id, coin_balance=0, cash_balance_cents=0)
|
||||
db.add(acc)
|
||||
db.flush()
|
||||
now = datetime.now(CN_TZ).replace(tzinfo=None)
|
||||
made = 0
|
||||
for i, (biz, remark, coin) in enumerate(CASES):
|
||||
ref = f"{REF_PREFIX}:{biz}:{i}"
|
||||
exists = db.query(CoinTransaction).filter(CoinTransaction.ref_id == ref).first()
|
||||
if exists:
|
||||
continue
|
||||
acc.coin_balance += coin
|
||||
db.add(CoinTransaction(
|
||||
user_id=u.id, amount=coin, balance_after=acc.coin_balance,
|
||||
biz_type=biz, ref_id=ref, remark=remark or None, created_at=now,
|
||||
))
|
||||
made += 1
|
||||
db.commit()
|
||||
print(f"✓ 已给 user_id={u.id}({TEST_PHONE})造 {made} 条测试流水,当前金币余额 {acc.coin_balance}。")
|
||||
print(" → 打开手机 App「收益明细 / 金币记录」下拉刷新,逐条核对文案。")
|
||||
print(" → 验收完跑 --clean 删除这些测试流水。")
|
||||
|
||||
|
||||
def cmd_clean() -> None:
|
||||
if settings.APP_ENV != "dev":
|
||||
print(f"✗ 拒绝:APP_ENV={settings.APP_ENV}。")
|
||||
sys.exit(1)
|
||||
db = SessionLocal()
|
||||
u = _get_user(db)
|
||||
rows = db.query(CoinTransaction).filter(
|
||||
CoinTransaction.user_id == u.id,
|
||||
CoinTransaction.ref_id.like(f"{REF_PREFIX}:%"),
|
||||
).all()
|
||||
total = sum(r.amount for r in rows)
|
||||
for r in rows:
|
||||
db.delete(r)
|
||||
acc = db.query(CoinAccount).filter(CoinAccount.user_id == u.id).first()
|
||||
if acc is not None:
|
||||
acc.coin_balance -= total # 把造流水时加的余额扣回,还原
|
||||
db.commit()
|
||||
print(f"✓ 已删除 {len(rows)} 条 TESTDOC 测试流水,余额回扣 {total},当前 {acc.coin_balance if acc else 0}。")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description="收益明细金币文案验证脚手架")
|
||||
g = ap.add_mutually_exclusive_group(required=True)
|
||||
g.add_argument("--show", action="store_true", help="只打印每种 bizType 的预期显示文案,不写库")
|
||||
g.add_argument("--seed", action="store_true", help="往测试号造每种 bizType 各一条流水")
|
||||
g.add_argument("--clean", action="store_true", help="删除本脚本造的所有测试流水")
|
||||
args = ap.parse_args()
|
||||
if args.show:
|
||||
cmd_show()
|
||||
elif args.seed:
|
||||
cmd_seed()
|
||||
elif args.clean:
|
||||
cmd_clean()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,73 @@
|
||||
"""ad_ecpm_record.trace_id 落库 + 按 trace 聚合广告收益(元)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import delete
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.ad_ecpm import AdEcpmRecord
|
||||
from app.repositories import ad_ecpm as crud_ecpm
|
||||
|
||||
|
||||
def _ecpm(trace_id: str, ecpm_raw: str, session_id: str) -> AdEcpmRecord:
|
||||
"""构造一条 Draw 展示 eCPM(不 commit;ad_session_id 全局唯一,须各不相同)。"""
|
||||
return AdEcpmRecord(
|
||||
user_id=1,
|
||||
ad_type="draw",
|
||||
feed_scene="comparison",
|
||||
ad_session_id=session_id,
|
||||
ecpm_raw=ecpm_raw,
|
||||
trace_id=trace_id,
|
||||
report_date="2020-01-02",
|
||||
created_at=datetime(2020, 1, 2, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
def test_revenue_yuan_by_trace_sums_and_clamps() -> None:
|
||||
"""同一 trace 多条展示求和;收益=min(eCPM元,¥500)/1000;无展示的 trace 不出现。
|
||||
|
||||
ecpm 200 分→2.0 元/千次→0.002 元/次;300 分→0.003;合计 0.005。
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add_all([
|
||||
_ecpm("t1", "200", "sess-t1-a"),
|
||||
_ecpm("t1", "300", "sess-t1-b"),
|
||||
_ecpm("t2", "0", "sess-t2-a"),
|
||||
_ecpm("t_cap", "60000", "sess-cap-a"), # 600 元 CPM > ¥500 钳顶
|
||||
])
|
||||
db.flush()
|
||||
rev = crud_ecpm.revenue_yuan_by_trace(db, ["t1", "t2", "t3", "t_cap"])
|
||||
assert rev["t1"] == 0.005
|
||||
assert rev["t2"] == 0.0 # 有展示但 eCPM=0 → 0 元(仍在结果里)
|
||||
assert "t3" not in rev # 无展示的 trace 不出现在结果里
|
||||
assert rev["t_cap"] == 0.5 # min(¥600, ¥500)/1000,证明钳顶生效(非 0.6)
|
||||
finally:
|
||||
db.rollback()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_revenue_yuan_by_trace_empty() -> None:
|
||||
"""空 trace 列表直接返回 {}(避免 IN () 非法)。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
assert crud_ecpm.revenue_yuan_by_trace(db, []) == {}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_create_ecpm_record_persists_trace_id() -> None:
|
||||
"""create_ecpm_record 落 trace_id。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rec = crud_ecpm.create_ecpm_record(
|
||||
db, 1, ad_type="draw", ecpm_raw="150",
|
||||
ad_session_id="sess-trace-persist", feed_scene="coupon",
|
||||
trace_id="trace-xyz",
|
||||
)
|
||||
assert rec.trace_id == "trace-xyz"
|
||||
finally:
|
||||
db.execute(delete(AdEcpmRecord).where(AdEcpmRecord.ad_session_id == "sess-trace-persist"))
|
||||
db.commit()
|
||||
db.close()
|
||||
@@ -320,3 +320,92 @@ def test_read_apis_require_auth(admin_client: TestClient) -> None:
|
||||
"/admin/api/feedbacks",
|
||||
]:
|
||||
assert admin_client.get(path).status_code == 401, path
|
||||
|
||||
|
||||
def test_period_comparison_reward_coin_from_feed_scene(
|
||||
admin_client: TestClient, admin_token: str
|
||||
) -> None:
|
||||
"""比价奖励金币口径:按 ad_feed_reward_record.feed_scene='comparison' 的实发金币
|
||||
(status=granted)汇总,而非查从不写入的 biz_type 桶(修复大盘该卡恒 0)。
|
||||
too_short(未发奖)不计。"""
|
||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||
|
||||
d = "2021-06-15" # 独立历史日,隔离其它用例数据
|
||||
db = SessionLocal()
|
||||
try:
|
||||
uid = user_repo.upsert_user_for_login(db, phone="13800008801", register_channel="sms").id
|
||||
db.add(AdFeedRewardRecord(
|
||||
client_event_id="cmp-fs-granted", user_id=uid, reward_date=d,
|
||||
ecpm_raw="0", coin=123, feed_scene="comparison", status="granted",
|
||||
))
|
||||
db.add(AdFeedRewardRecord(
|
||||
client_event_id="cmp-fs-tooshort", user_id=uid, reward_date=d,
|
||||
ecpm_raw="0", coin=99, feed_scene="comparison", status="too_short",
|
||||
))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
r = admin_client.get(
|
||||
"/admin/api/stats/overview", params={"date_from": d, "date_to": d},
|
||||
headers=_auth(admin_token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["period"]["coins"]["comparison_reward_coin_total"] == 123
|
||||
|
||||
|
||||
def test_period_coupon_reward_coin_from_feed_scene(
|
||||
admin_client: TestClient, admin_token: str
|
||||
) -> None:
|
||||
"""领券奖励金币口径:按 ad_feed_reward_record.feed_scene='coupon' 的实发金币汇总。"""
|
||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||
|
||||
d = "2021-06-16"
|
||||
db = SessionLocal()
|
||||
try:
|
||||
uid = user_repo.upsert_user_for_login(db, phone="13800008802", register_channel="sms").id
|
||||
db.add(AdFeedRewardRecord(
|
||||
client_event_id="cpn-fs-granted", user_id=uid, reward_date=d,
|
||||
ecpm_raw="0", coin=456, feed_scene="coupon", status="granted",
|
||||
))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
r = admin_client.get(
|
||||
"/admin/api/stats/overview", params={"date_from": d, "date_to": d},
|
||||
headers=_auth(admin_token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["period"]["coins"]["coupon_reward_coin_total"] == 456
|
||||
|
||||
|
||||
def test_period_coupon_reward_excludes_reward_video(
|
||||
admin_client: TestClient, admin_token: str
|
||||
) -> None:
|
||||
"""领券奖励金币不再把激励视频金币算进来(reward_video/ad_reward 从领券桶拆出);
|
||||
激励视频仍单独计入 reward_video_coin_total。"""
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.wallet import CoinTransaction
|
||||
|
||||
d = "2021-06-17"
|
||||
db = SessionLocal()
|
||||
try:
|
||||
uid = user_repo.upsert_user_for_login(db, phone="13800008803", register_channel="sms").id
|
||||
db.add(CoinTransaction(
|
||||
user_id=uid, amount=50, balance_after=50, biz_type="reward_video",
|
||||
ref_id="rv-split-1", created_at=datetime(2021, 6, 17, 12, 0, 0),
|
||||
))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
r = admin_client.get(
|
||||
"/admin/api/stats/overview", params={"date_from": d, "date_to": d},
|
||||
headers=_auth(admin_token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
coins = r.json()["period"]["coins"]
|
||||
assert coins["coupon_reward_coin_total"] == 0 # 激励视频不计入领券奖励
|
||||
assert coins["reward_video_coin_total"] == 50 # 仍计入激励视频卡
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""埋点健康度聚合:纯差分函数 + admin 端点。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.admin.repositories.analytics_health import _cn_day, _rates, diff_snapshots
|
||||
|
||||
|
||||
def _row(device, epoch, event, ts, **cum) -> dict:
|
||||
base = {"attempted": 0, "drop_capture": 0, "delivered": 0, "drop_undelivered": 0}
|
||||
base.update(cum)
|
||||
return {
|
||||
"device_id": device, "epoch_id": epoch, "event": event,
|
||||
"created_at": datetime(2026, 7, 1, ts, 0, tzinfo=timezone.utc),
|
||||
"app_ver": "0.2.12(62)", "oem": "ColorOS", "os": "Android 14",
|
||||
**base,
|
||||
}
|
||||
|
||||
|
||||
def test_diff_first_row_is_full_cumulative() -> None:
|
||||
rows = [_row("d", "e", "video_play", 1, attempted=100, delivered=90)]
|
||||
out = diff_snapshots(rows)
|
||||
assert len(out) == 1
|
||||
assert out[0]["d_attempted"] == 100
|
||||
assert out[0]["d_delivered"] == 90
|
||||
|
||||
|
||||
def test_diff_consecutive_delta() -> None:
|
||||
rows = [
|
||||
_row("d", "e", "video_play", 1, attempted=100, delivered=90),
|
||||
_row("d", "e", "video_play", 2, attempted=150, delivered=140),
|
||||
]
|
||||
out = sorted(diff_snapshots(rows), key=lambda r: r["created_at"])
|
||||
assert out[1]["d_attempted"] == 50
|
||||
assert out[1]["d_delivered"] == 50
|
||||
|
||||
|
||||
def test_diff_epoch_reset_new_partition() -> None:
|
||||
rows = [
|
||||
_row("d", "e1", "video_play", 1, attempted=100),
|
||||
_row("d", "e2", "video_play", 2, attempted=5),
|
||||
]
|
||||
out = {(r["epoch_id"]): r["d_attempted"] for r in diff_snapshots(rows)}
|
||||
assert out["e1"] == 100
|
||||
assert out["e2"] == 5
|
||||
|
||||
|
||||
def test_diff_clamps_negative_on_reorder() -> None:
|
||||
rows = [
|
||||
_row("d", "e", "video_play", 1, attempted=100),
|
||||
_row("d", "e", "video_play", 2, attempted=80),
|
||||
]
|
||||
out = sorted(diff_snapshots(rows), key=lambda r: r["created_at"])
|
||||
assert out[1]["d_attempted"] == 0
|
||||
|
||||
|
||||
def test_rates_computes_both_formulas() -> None:
|
||||
out = _rates({"attempted": 150, "drop_capture": 0, "delivered": 140, "drop_undelivered": 10})
|
||||
assert out["track_success_rate"] == 1.0
|
||||
assert out["report_success_rate"] == 140 / 150
|
||||
|
||||
|
||||
def test_rates_zero_denominator_yields_none() -> None:
|
||||
out = _rates({"attempted": 0, "drop_capture": 0, "delivered": 0, "drop_undelivered": 0})
|
||||
assert out["track_success_rate"] is None
|
||||
assert out["report_success_rate"] is None
|
||||
|
||||
|
||||
def test_cn_day_beijing_boundary() -> None:
|
||||
# UTC 15:59 → 北京 23:59 同日;UTC 16:00 → 北京 次日 00:00
|
||||
assert _cn_day(datetime(2026, 7, 1, 15, 59, tzinfo=timezone.utc)) == "2026-07-01"
|
||||
assert _cn_day(datetime(2026, 7, 1, 16, 0, tzinfo=timezone.utc)) == "2026-07-02"
|
||||
|
||||
|
||||
def test_diff_same_timestamp_ordered_by_id() -> None:
|
||||
ts = datetime(2026, 7, 1, 1, 0, tzinfo=timezone.utc)
|
||||
# 相同 created_at,乱序传入(id=2 在前);应按 id 排序 → id=1(cum100) 在前
|
||||
rows = [
|
||||
{"id": 2, "device_id": "d", "epoch_id": "e", "event": "vp", "created_at": ts,
|
||||
"app_ver": "v", "oem": "o", "os": "s",
|
||||
"attempted": 150, "drop_capture": 0, "delivered": 0, "drop_undelivered": 0},
|
||||
{"id": 1, "device_id": "d", "epoch_id": "e", "event": "vp", "created_at": ts,
|
||||
"app_ver": "v", "oem": "o", "os": "s",
|
||||
"attempted": 100, "drop_capture": 0, "delivered": 0, "drop_undelivered": 0},
|
||||
]
|
||||
out = diff_snapshots(rows)
|
||||
assert out[0]["d_attempted"] == 100 # id=1 sorts first → its cumulative
|
||||
assert out[1]["d_attempted"] == 50 # id=2 second → 150-100
|
||||
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.admin.main import admin_app
|
||||
from app.admin.repositories import admin_user as admin_repo
|
||||
from app.db.session import SessionLocal
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def admin_client() -> TestClient:
|
||||
return TestClient(admin_app)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def admin_token() -> str:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if admin_repo.get_by_username(db, "health_admin") is None:
|
||||
admin_repo.create_admin(db, username="health_admin", password="pw", role="super_admin")
|
||||
finally:
|
||||
db.close()
|
||||
c = TestClient(admin_app)
|
||||
r = c.post("/admin/api/auth/login", json={"username": "health_admin", "password": "pw"})
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def _auth(t: str) -> dict:
|
||||
return {"Authorization": f"Bearer {t}"}
|
||||
|
||||
|
||||
def _seed_two_snapshots(client: TestClient) -> None:
|
||||
for attempted, delivered in ((100, 90), (150, 140)):
|
||||
client.post("/api/v1/analytics/selfstat", json={
|
||||
"device_id": "d-health", "epoch_id": "e-health",
|
||||
"app_ver": "0.2.12(62)", "oem": "ColorOS", "os": "Android 14",
|
||||
"events": [{"event": "video_play", "attempted": attempted,
|
||||
"drop_capture": 0, "delivered": delivered, "drop_undelivered": 0}],
|
||||
})
|
||||
|
||||
|
||||
def test_health_overview_requires_auth(admin_client: TestClient) -> None:
|
||||
r = admin_client.get("/admin/api/analytics-health/overview",
|
||||
params={"date_from": "2026-07-01T00:00:00Z", "date_to": "2030-01-01T00:00:00Z"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_health_overview(client: TestClient, admin_client: TestClient, admin_token: str) -> None:
|
||||
_seed_two_snapshots(client) # 播种走公开 app(ingest 端点在 app,不在 admin_app)
|
||||
r = admin_client.get(
|
||||
"/admin/api/analytics-health/overview",
|
||||
params={"date_from": "2026-07-01T00:00:00Z", "date_to": "2030-01-01T00:00:00Z"},
|
||||
headers=_auth(admin_token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
# 两快照累计 100→150,差分后 attempted 总 150(首快照 100 + 增量 50)
|
||||
assert data["attempted"] == 150
|
||||
assert data["track_success_rate"] == 1.0
|
||||
|
||||
|
||||
def test_health_breakdown(client: TestClient, admin_client: TestClient, admin_token: str) -> None:
|
||||
_seed_two_snapshots(client) # 播种走公开 app
|
||||
r = admin_client.get(
|
||||
"/admin/api/analytics-health/breakdown",
|
||||
params={"date_from": "2026-07-01T00:00:00Z", "date_to": "2030-01-01T00:00:00Z", "dim": "event"},
|
||||
headers=_auth(admin_token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
rows = r.json()
|
||||
assert any(row["key"] == "video_play" for row in rows)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""自报计数上报端点测试。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def _payload(**over) -> dict:
|
||||
base = {
|
||||
"device_id": "dev-1", "epoch_id": "ep-1", "sent_at": 1700000000000,
|
||||
"app_ver": "0.2.12(62)", "oem": "ColorOS", "os": "Android 14",
|
||||
"batches_attempted": 10, "batches_ok": 9, "batches_fail": 1,
|
||||
"retries": 1, "queue_depth": 2,
|
||||
"events": [
|
||||
{"event": "video_play", "attempted": 100, "drop_capture": 1,
|
||||
"delivered": 95, "drop_undelivered": 2},
|
||||
],
|
||||
}
|
||||
base.update(over)
|
||||
return base
|
||||
|
||||
|
||||
def test_selfstat_ingest_ok(client: TestClient) -> None:
|
||||
r = client.post("/api/v1/analytics/selfstat", json=_payload())
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["ok"] is True
|
||||
assert isinstance(body["snapshot_id"], int)
|
||||
|
||||
|
||||
def test_selfstat_ingest_empty_events(client: TestClient) -> None:
|
||||
r = client.post("/api/v1/analytics/selfstat", json=_payload(events=[]))
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["ok"] is True
|
||||
|
||||
|
||||
def test_selfstat_ingest_db_error_returns_503(client: TestClient, monkeypatch) -> None:
|
||||
"""落库异常时端点回 503(计数链路稳定性守卫),不裸奔 500。"""
|
||||
def _boom(*_args, **_kwargs):
|
||||
raise RuntimeError("db gone")
|
||||
|
||||
monkeypatch.setattr("app.repositories.analytics_selfstat.record_selfstat", _boom)
|
||||
r = client.post("/api/v1/analytics/selfstat", json=_payload())
|
||||
assert r.status_code == 503, r.text
|
||||
@@ -0,0 +1,87 @@
|
||||
"""两个看板逐行「本次广告收益」(元):按 trace_id 聚合 ad_ecpm_record。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
|
||||
from sqlalchemy import delete
|
||||
|
||||
from app.admin.repositories import queries
|
||||
from app.admin.repositories.coupon_data import coupon_data_report, coupon_user_records
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.ad_ecpm import AdEcpmRecord
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.coupon_state import CouponSession
|
||||
|
||||
|
||||
def _ecpm(trace_id: str, ecpm_raw: str, session_id: str, scene: str) -> AdEcpmRecord:
|
||||
return AdEcpmRecord(
|
||||
user_id=1, ad_type="draw", feed_scene=scene, ad_session_id=session_id,
|
||||
ecpm_raw=ecpm_raw, trace_id=trace_id, report_date="2020-01-02",
|
||||
created_at=datetime(2020, 1, 2, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
def test_coupon_data_report_includes_ad_revenue() -> None:
|
||||
"""领券看板明细行带本次广告收益;200+300 分 → 0.005 元。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add(CouponSession(
|
||||
trace_id="rev-cp-1", device_id="d1", status="completed", app_env="prod",
|
||||
platforms=["meituan-waimai"], platform_success=["meituan-waimai"],
|
||||
started_at=datetime(2020, 1, 2, tzinfo=UTC), started_date=date(2020, 1, 2),
|
||||
))
|
||||
db.add_all([
|
||||
_ecpm("rev-cp-1", "200", "cp-sess-a", "coupon"),
|
||||
_ecpm("rev-cp-1", "300", "cp-sess-b", "coupon"),
|
||||
])
|
||||
db.flush()
|
||||
res = coupon_data_report(db, date_from="2020-01-02", date_to="2020-01-02", app_env="prod")
|
||||
row = next(r for r in res["items"] if r["trace_id"] == "rev-cp-1")
|
||||
assert row["ad_revenue_yuan"] == 0.005
|
||||
finally:
|
||||
db.rollback()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_comparison_list_includes_ad_revenue() -> None:
|
||||
"""比价记录列表项带本次广告收益;200 分 → 0.002 元。
|
||||
|
||||
用独有 user_id 过滤,确保本行必落在第一页(避免共享测试库里同 user 记录多、分页把它挤掉)。
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add(ComparisonRecord(
|
||||
trace_id="rev-cmp-1", user_id=987654, status="success", business_type="food",
|
||||
created_at=datetime(2020, 1, 2, tzinfo=UTC),
|
||||
))
|
||||
db.add(_ecpm("rev-cmp-1", "200", "cmp-sess-a", "comparison"))
|
||||
db.commit()
|
||||
items, _next, _total = queries.list_comparison_records(db, user_id=987654)
|
||||
row = next(it for it in items if it.trace_id == "rev-cmp-1")
|
||||
assert row.ad_revenue_yuan == 0.002
|
||||
finally:
|
||||
db.execute(delete(AdEcpmRecord).where(AdEcpmRecord.trace_id.in_(["rev-cmp-1"])))
|
||||
db.execute(delete(ComparisonRecord).where(ComparisonRecord.trace_id == "rev-cmp-1"))
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_coupon_user_records_includes_ad_revenue() -> None:
|
||||
"""手机号抽屉的用户领券记录也带本次广告收益(400 分 → 0.004 元)。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add(CouponSession(
|
||||
trace_id="rev-drawer-1", device_id="d2", status="completed", app_env="prod",
|
||||
user_id=987655, platforms=["meituan-waimai"], platform_success=["meituan-waimai"],
|
||||
started_at=datetime(2020, 1, 2, tzinfo=UTC), started_date=date(2020, 1, 2),
|
||||
))
|
||||
db.add(_ecpm("rev-drawer-1", "400", "drawer-sess-a", "coupon"))
|
||||
db.commit()
|
||||
res = coupon_user_records(db, user_id=987655)
|
||||
row = next(r for r in res["items"] if r["trace_id"] == "rev-drawer-1")
|
||||
assert row["ad_revenue_yuan"] == 0.004
|
||||
finally:
|
||||
db.execute(delete(AdEcpmRecord).where(AdEcpmRecord.trace_id == "rev-drawer-1"))
|
||||
db.execute(delete(CouponSession).where(CouponSession.trace_id == "rev-drawer-1"))
|
||||
db.commit()
|
||||
db.close()
|
||||
@@ -0,0 +1,299 @@
|
||||
"""领券「平台成功率」埋点:coupon_id→平台映射 / 成功平台推导 / session platform_success 并集。
|
||||
|
||||
设计:docs/guides/领券成功率指标-设计与埋点.md
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
|
||||
from app.admin.repositories.coupon_data import coupon_data_report
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.coupon_state import CouponSession
|
||||
from app.repositories.coupon_state import (
|
||||
coupon_id_to_platform,
|
||||
merge_session_platform_success,
|
||||
succeeded_platforms,
|
||||
)
|
||||
|
||||
|
||||
def _agg_session(
|
||||
trace: str, platforms, platform_success, *, status: str = "completed"
|
||||
) -> CouponSession:
|
||||
"""构造一条聚合测试用 session(started_date 固定 2020-01-02、app_env=prod,不 commit)。"""
|
||||
return CouponSession(
|
||||
trace_id=trace,
|
||||
device_id="d-agg",
|
||||
status=status,
|
||||
app_env="prod",
|
||||
platforms=platforms,
|
||||
platform_success=platform_success,
|
||||
started_at=datetime(2020, 1, 2, tzinfo=UTC),
|
||||
started_date=date(2020, 1, 2),
|
||||
)
|
||||
|
||||
|
||||
def _make_session(db, trace_id: str, **kw) -> CouponSession:
|
||||
row = CouponSession(
|
||||
trace_id=trace_id,
|
||||
device_id="dev-merge",
|
||||
status=kw.pop("status", "started"),
|
||||
started_at=datetime(2020, 1, 1, tzinfo=UTC),
|
||||
started_date=date(2020, 1, 1),
|
||||
**kw,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
return row
|
||||
|
||||
|
||||
def test_coupon_id_to_platform_prefix_mapping() -> None:
|
||||
"""coupon_id 前缀 → 三档平台 id(与客户端 couponIdToPlatform 同词表)。"""
|
||||
assert coupon_id_to_platform("mt_banjia_zhoumo") == "meituan-waimai"
|
||||
assert coupon_id_to_platform("mt_cps_waimai_redpacket") == "meituan-waimai" # 大众点评CPS 也挂 mt_ → 归美团
|
||||
assert coupon_id_to_platform("tb_vip_shangou_voucher") == "taobao-shanguang"
|
||||
assert coupon_id_to_platform("ele_hongbao") == "taobao-shanguang"
|
||||
assert coupon_id_to_platform("elm_hongbao") == "taobao-shanguang"
|
||||
assert coupon_id_to_platform("jd_redpacket") == "jd-waimai"
|
||||
|
||||
|
||||
def test_coupon_id_to_platform_unknown_returns_none() -> None:
|
||||
"""无法识别的前缀 / 空 → None(调用方跳过,不计入平台)。"""
|
||||
assert coupon_id_to_platform("weird_xxx") is None
|
||||
assert coupon_id_to_platform("") is None
|
||||
assert coupon_id_to_platform(None) is None # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_succeeded_platforms_filters_status_and_dedups() -> None:
|
||||
"""只取 status∈{success,already_claimed} 的券,映射平台后去重;失败/跳过/无法识别的不计。"""
|
||||
results = [
|
||||
{"coupon_id": "mt_a", "status": "success"},
|
||||
{"coupon_id": "mt_b", "status": "already_claimed"}, # 也算成功 → 仍是美团
|
||||
{"coupon_id": "mt_c", "status": "failed"}, # 不算
|
||||
{"coupon_id": "tb_a", "status": "success"},
|
||||
{"coupon_id": "jd_a", "status": "skipped"}, # 不算
|
||||
{"coupon_id": "weird", "status": "success"}, # 无法识别平台 → 跳过
|
||||
]
|
||||
assert set(succeeded_platforms(results)) == {"meituan-waimai", "taobao-shanguang"}
|
||||
|
||||
|
||||
def test_succeeded_platforms_empty() -> None:
|
||||
assert succeeded_platforms([]) == []
|
||||
|
||||
|
||||
def test_merge_platform_success_union_idempotent() -> None:
|
||||
"""按 trace_id 把成功平台并入 platform_success:并集去重 + 按 DEFAULT_PLATFORMS 保序 + 重复并入幂等。"""
|
||||
db = SessionLocal()
|
||||
trace = "merge-union-1"
|
||||
try:
|
||||
_make_session(db, trace)
|
||||
merge_session_platform_success(db, trace, ["meituan-waimai"])
|
||||
merge_session_platform_success(db, trace, ["jd-waimai", "meituan-waimai"]) # 并集 + 已有幂等
|
||||
db.expire_all()
|
||||
row = db.execute(
|
||||
select(CouponSession).where(CouponSession.trace_id == trace)
|
||||
).scalar_one()
|
||||
# 美团→淘宝→京东固定序;只含实际成功的美团/京东
|
||||
assert row.platform_success == ["meituan-waimai", "jd-waimai"]
|
||||
finally:
|
||||
db.execute(delete(CouponSession).where(CouponSession.trace_id == trace))
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_merge_platform_success_missing_row_skips() -> None:
|
||||
"""trace_id 无对应行 → 静默跳过:不建兜底行、不抛异常(设计 §5)。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
merge_session_platform_success(db, "no-such-trace", ["meituan-waimai"])
|
||||
n = db.execute(
|
||||
select(func.count()).select_from(CouponSession).where(
|
||||
CouponSession.trace_id == "no-such-trace"
|
||||
)
|
||||
).scalar_one()
|
||||
assert n == 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_coupon_data_success_rates() -> None:
|
||||
"""admin 聚合 ②整单成功率 / ③点位成功率:平台粒度,含空 platforms→全领三档、abandoned 入基数。
|
||||
|
||||
A 勾美团+淘宝、两个都成 → 整单成功;点位 2/2
|
||||
B 勾三档、只成美团 → 非整单;点位 1/3
|
||||
C 全领(platforms空→3)、三档全成 → 整单成功;点位 3/3
|
||||
D abandoned、勾美团、无成功平台 → 非整单;点位 0/1(仍进基数)
|
||||
发起数=4;整单成功=2 → 0.5;点位 6/9 → 0.6667
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add_all([
|
||||
_agg_session("agg-A", ["meituan-waimai", "taobao-shanguang"],
|
||||
["meituan-waimai", "taobao-shanguang"]),
|
||||
_agg_session("agg-B", ["meituan-waimai", "taobao-shanguang", "jd-waimai"],
|
||||
["meituan-waimai"]),
|
||||
_agg_session("agg-C", [], ["meituan-waimai", "taobao-shanguang", "jd-waimai"]),
|
||||
_agg_session("agg-D", ["meituan-waimai"], None, status="abandoned"),
|
||||
])
|
||||
db.flush() # 同会话可见,不 commit(finally 回滚保持隔离)
|
||||
s = coupon_data_report(
|
||||
db, date_from="2020-01-02", date_to="2020-01-02", app_env="prod"
|
||||
)["summary"]
|
||||
assert s["started_count"] == 4
|
||||
assert s["full_success_count"] == 2
|
||||
assert s["full_success_rate"] == 0.5
|
||||
assert s["point_success_count"] == 6
|
||||
assert s["point_total_count"] == 9
|
||||
assert s["point_success_rate"] == round(6 / 9, 4)
|
||||
# ③ 分平台点位成功率(§12):美团 3/4、淘宝 2/3、京东 1/2。
|
||||
# 分平台(成功,总)= 美团(3,4)+淘宝(2,3)+京东(1,2) = (6,9),
|
||||
# 其和正好等于上面已断言的 point_success_count=6 / point_total_count=9(和不变量)。
|
||||
assert s["per_platform"] == {
|
||||
"meituan-waimai": 0.75,
|
||||
"taobao-shanguang": round(2 / 3, 4),
|
||||
"jd-waimai": 0.5,
|
||||
}
|
||||
finally:
|
||||
db.rollback()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_coupon_data_success_rates_empty_range() -> None:
|
||||
"""区间无 session → 发起数 0、两个率为 None(不除零)。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
s = coupon_data_report(
|
||||
db, date_from="2019-01-01", date_to="2019-01-01", app_env="prod"
|
||||
)["summary"]
|
||||
assert s["started_count"] == 0
|
||||
assert s["full_success_rate"] is None
|
||||
assert s["point_success_rate"] is None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_coupon_data_summary_schema_exposes_rates() -> None:
|
||||
"""schema 契约:CouponDataSummary 暴露 ②③ 字段,键名与 repo 输出一致(router 直接 **summary 构造)。"""
|
||||
from app.admin.schemas.coupon_data import CouponDataSummary
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add(_agg_session("agg-schema-1", ["meituan-waimai"], ["meituan-waimai"]))
|
||||
db.flush()
|
||||
summary = coupon_data_report(
|
||||
db, date_from="2020-01-02", date_to="2020-01-02", app_env="prod"
|
||||
)["summary"]
|
||||
dumped = CouponDataSummary(**summary).model_dump()
|
||||
assert dumped["full_success_rate"] == summary["full_success_rate"]
|
||||
assert dumped["point_success_rate"] == summary["point_success_rate"]
|
||||
assert dumped["full_success_count"] == summary["full_success_count"]
|
||||
assert dumped["point_success_count"] == summary["point_success_count"]
|
||||
assert dumped["point_total_count"] == summary["point_total_count"]
|
||||
assert dumped["per_platform"] == summary["per_platform"]
|
||||
finally:
|
||||
db.rollback()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_step_writes_platform_success_to_session(client) -> None:
|
||||
"""/step 集成:pricebot 返 coupon_results → 本 trace 的 coupon_session.platform_success 落库(仅成功平台)。"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
|
||||
from app.models.coupon_state import CouponClaimRecord, CouponDailyCompletion
|
||||
|
||||
trace = "int-trace-1"
|
||||
device = "dev-int-1"
|
||||
|
||||
# 预置一条 started session(模拟 /session started 已先落库)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add(CouponSession(
|
||||
trace_id=trace, device_id=device, status="started", app_env="dev",
|
||||
platforms=["meituan-waimai", "taobao-shanguang"],
|
||||
started_at=datetime(2020, 1, 5, tzinfo=UTC),
|
||||
started_date=date(2020, 1, 5),
|
||||
))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
fake_resp = {
|
||||
"success": True,
|
||||
"action": {"command": "done", "params": {
|
||||
"information": "已领 1 张",
|
||||
"coupon_results": [
|
||||
{"coupon_id": "mt_x", "name": "美团券", "vendor": "meituan_internal", "status": "success"},
|
||||
{"coupon_id": "tb_y", "name": "淘宝券", "vendor": "taobao", "status": "failed"},
|
||||
],
|
||||
}},
|
||||
"continue": False,
|
||||
}
|
||||
|
||||
async def fake_post(self, url, **kw):
|
||||
m = MagicMock()
|
||||
m.status_code = 200
|
||||
m.json = lambda: fake_resp
|
||||
return m
|
||||
|
||||
body = {
|
||||
"device_id": device, "trace_id": trace, "step": 5,
|
||||
"screen_state": {"screen": {"width": 1080, "height": 2340, "density": 3.0},
|
||||
"foreground": {"package": "x", "activity": ""}, "windows": []},
|
||||
}
|
||||
|
||||
try:
|
||||
with patch.object(httpx.AsyncClient, "post", fake_post):
|
||||
r = client.post("/api/v1/coupon/step", json=body)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
row = db.execute(
|
||||
select(CouponSession).where(CouponSession.trace_id == trace)
|
||||
).scalar_one()
|
||||
assert row.platform_success == ["meituan-waimai"] # tb 失败不计入
|
||||
claims = db.execute(
|
||||
select(CouponClaimRecord).where(CouponClaimRecord.device_id == device)
|
||||
).scalars().all()
|
||||
assert claims and all(c.app_env == "dev" for c in claims) # session app_env 打标
|
||||
finally:
|
||||
db.close()
|
||||
finally:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.execute(delete(CouponSession).where(CouponSession.trace_id == trace))
|
||||
db.execute(delete(CouponClaimRecord).where(CouponClaimRecord.device_id == device))
|
||||
db.execute(delete(CouponDailyCompletion).where(CouponDailyCompletion.device_id == device))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_coupon_data_status_filter() -> None:
|
||||
"""状态多选过滤(方案 A):整个视图按选中状态算;None/空=全部。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add_all([
|
||||
_agg_session("st-A", ["meituan-waimai"], ["meituan-waimai"], status="completed"),
|
||||
_agg_session("st-B", ["meituan-waimai"], ["meituan-waimai"], status="started"),
|
||||
_agg_session("st-C", ["meituan-waimai"], None, status="failed"),
|
||||
_agg_session("st-D", ["meituan-waimai"], ["meituan-waimai"], status="abandoned"),
|
||||
])
|
||||
db.flush()
|
||||
base = dict(date_from="2020-01-02", date_to="2020-01-02", app_env="prod")
|
||||
# None = 全部 4 发起
|
||||
assert coupon_data_report(db, **base)["summary"]["started_count"] == 4
|
||||
# 排除 started → 3 发起(整个视图,发起数也随之变)
|
||||
sub = coupon_data_report(db, **base, statuses=["completed", "failed", "abandoned"])["summary"]
|
||||
assert sub["started_count"] == 3
|
||||
assert sub["completed_count"] == 1
|
||||
# 只 completed → 发起数=1、整单成功率基数=1(该 completed 是整单成功)
|
||||
comp = coupon_data_report(db, **base, statuses=["completed"])["summary"]
|
||||
assert comp["started_count"] == 1
|
||||
assert comp["full_success_rate"] == 1.0
|
||||
finally:
|
||||
db.rollback()
|
||||
db.close()
|
||||
@@ -0,0 +1,137 @@
|
||||
"""每券成功率(§13):app_env 落库 + coupon_slot_report 聚合。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.admin.repositories.coupon_data import coupon_slot_report
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.coupon_state import CouponClaimRecord, CouponSession
|
||||
from app.repositories.coupon_state import record_claims, session_app_env
|
||||
|
||||
|
||||
def test_session_app_env_lookup() -> None:
|
||||
db = SessionLocal()
|
||||
trace = "slot-env-1"
|
||||
try:
|
||||
db.add(CouponSession(
|
||||
trace_id=trace, device_id="d-slot", status="started", app_env="dev",
|
||||
started_at=datetime(2020, 2, 1, tzinfo=UTC), started_date=date(2020, 2, 1),
|
||||
))
|
||||
db.commit()
|
||||
assert session_app_env(db, trace) == "dev"
|
||||
assert session_app_env(db, "no-such-trace") is None
|
||||
assert session_app_env(db, None) is None
|
||||
finally:
|
||||
db.execute(delete(CouponSession).where(CouponSession.trace_id == trace))
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_record_claims_stamps_app_env() -> None:
|
||||
db = SessionLocal()
|
||||
dev = "d-slot-stamp"
|
||||
try:
|
||||
record_claims(db, dev, None, "t-stamp",
|
||||
[{"coupon_id": "mt_x", "status": "success", "name": "美团券"}],
|
||||
app_env="prod")
|
||||
row = db.execute(select(CouponClaimRecord).where(
|
||||
CouponClaimRecord.device_id == dev)).scalar_one()
|
||||
assert row.app_env == "prod"
|
||||
# UPDATE 路:app_env=None 不覆盖已有值
|
||||
record_claims(db, dev, None, "t-stamp",
|
||||
[{"coupon_id": "mt_x", "status": "already_claimed", "name": "美团券"}],
|
||||
app_env=None)
|
||||
db.expire_all()
|
||||
row = db.execute(select(CouponClaimRecord).where(
|
||||
CouponClaimRecord.device_id == dev)).scalar_one()
|
||||
assert row.app_env == "prod"
|
||||
assert row.status == "already_claimed"
|
||||
finally:
|
||||
db.execute(delete(CouponClaimRecord).where(CouponClaimRecord.device_id == dev))
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def _claim(db, device, coupon_id, status, app_env, name=None, d=date(2020, 2, 2)):
|
||||
db.add(CouponClaimRecord(
|
||||
device_id=device, coupon_id=coupon_id, claim_date=d,
|
||||
status=status, coupon_name=name, app_env=app_env,
|
||||
))
|
||||
|
||||
|
||||
def test_coupon_slot_report_aggregates() -> None:
|
||||
"""按 coupon_id 聚合:tried=成功+失败(skipped 排除)、rate、env 过滤、tried 倒序。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# mt_a(prod):dev1 success + dev2 already_claimed + dev3 failed → 2/3;dev4 skipped 不计
|
||||
_claim(db, "sdev1", "mt_a", "success", "prod", "美团外卖红包天天领")
|
||||
_claim(db, "sdev2", "mt_a", "already_claimed", "prod", "美团外卖红包天天领")
|
||||
_claim(db, "sdev3", "mt_a", "failed", "prod", "美团外卖红包天天领")
|
||||
_claim(db, "sdev4", "mt_a", "skipped", "prod", "美团外卖红包天天领")
|
||||
_claim(db, "sdev1", "tb_b", "success", "prod", "淘宝券") # 1/1
|
||||
_claim(db, "sdev9", "mt_a", "failed", "dev", "美团外卖红包天天领") # 仅 dev/全部
|
||||
db.commit()
|
||||
|
||||
prod = coupon_slot_report(
|
||||
db, date_from="2020-02-02", date_to="2020-02-02", app_env="prod"
|
||||
)["items"]
|
||||
by_id = {r["coupon_id"]: r for r in prod}
|
||||
assert by_id["mt_a"]["tried"] == 3 # skipped 排除
|
||||
assert by_id["mt_a"]["succeeded"] == 2
|
||||
assert by_id["mt_a"]["success_rate"] == round(2 / 3, 4)
|
||||
assert by_id["mt_a"]["platform"] == "meituan-waimai"
|
||||
assert by_id["mt_a"]["coupon_name"] == "美团外卖红包天天领"
|
||||
assert by_id["tb_b"]["success_rate"] == 1.0
|
||||
assert by_id["tb_b"]["platform"] == "taobao-shanguang"
|
||||
assert [r["coupon_id"] for r in prod] == ["mt_a", "tb_b"] # tried 倒序
|
||||
|
||||
dev = coupon_slot_report(
|
||||
db, date_from="2020-02-02", date_to="2020-02-02", app_env="dev"
|
||||
)["items"]
|
||||
assert {r["coupon_id"]: r["tried"] for r in dev} == {"mt_a": 1}
|
||||
allenv = coupon_slot_report(
|
||||
db, date_from="2020-02-02", date_to="2020-02-02", app_env=None
|
||||
)["items"]
|
||||
assert {r["coupon_id"]: r["tried"] for r in allenv}["mt_a"] == 4
|
||||
finally:
|
||||
db.execute(delete(CouponClaimRecord).where(CouponClaimRecord.claim_date == date(2020, 2, 2)))
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_coupon_slots_endpoint() -> None:
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.admin.main import admin_app
|
||||
from app.admin.repositories import admin_user as admin_repo
|
||||
from app.admin.security import create_admin_token
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
_claim(db, "epdev1", "mt_ep", "success", "prod", "端点券", d=date(2020, 2, 3))
|
||||
_claim(db, "epdev2", "mt_ep", "failed", "prod", "端点券", d=date(2020, 2, 3))
|
||||
admin = admin_repo.create_admin(
|
||||
db, username="slot_admin", password="pass1234", role="super_admin"
|
||||
)
|
||||
token, _exp = create_admin_token(admin_id=admin.id, role=admin.role)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
try:
|
||||
c = TestClient(admin_app)
|
||||
r = c.get(
|
||||
"/admin/api/coupon-data/coupons",
|
||||
params={"date_from": "2020-02-03", "date_to": "2020-02-03", "app_env": "prod"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
row = next(x for x in r.json()["items"] if x["coupon_id"] == "mt_ep")
|
||||
assert row["tried"] == 2 and row["succeeded"] == 1 and row["success_rate"] == 0.5
|
||||
assert row["coupon_name"] == "端点券" and row["platform"] == "meituan-waimai"
|
||||
finally:
|
||||
db = SessionLocal()
|
||||
db.execute(delete(CouponClaimRecord).where(CouponClaimRecord.claim_date == date(2020, 2, 3)))
|
||||
db.commit()
|
||||
db.close()
|
||||
@@ -143,14 +143,15 @@ def test_two_accounts_withdraw_independent(client, monkeypatch) -> None:
|
||||
_reject(r1.json()["out_bill_no"]) # 退回 invite_cash + 结清活跃单
|
||||
r2 = client.post(
|
||||
"/api/v1/wallet/withdraw",
|
||||
json={"amount_cents": 100, "source": "coin_cash"},
|
||||
# 50 分 = 0.5 元档(7-9 起 coin_cash 只能提预设档位)
|
||||
json={"amount_cents": 50, "source": "coin_cash"},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r2.json()["status"] == "reviewing"
|
||||
|
||||
cash, invite_cash = _balances(client, token)
|
||||
assert invite_cash == 500 # 已退回
|
||||
assert cash == 300 # 扣了 cash 100
|
||||
assert cash == 350 # 扣了 cash 50
|
||||
|
||||
|
||||
def test_invite_me_returns_reward_stats(client) -> None:
|
||||
@@ -177,7 +178,8 @@ def test_withdraw_orders_source_filter(client, monkeypatch) -> None:
|
||||
_reject(r1.json()["out_bill_no"]) # 结清,才能提第二笔
|
||||
client.post(
|
||||
"/api/v1/wallet/withdraw",
|
||||
json={"amount_cents": 100, "source": "coin_cash"},
|
||||
# 50 分 = 0.5 元档(7-9 起 coin_cash 只能提预设档位)
|
||||
json={"amount_cents": 50, "source": "coin_cash"},
|
||||
headers=_auth(token),
|
||||
)
|
||||
|
||||
|
||||
@@ -139,7 +139,8 @@ def test_coin_cash_withdraw_still_reconciled(client, monkeypatch) -> None:
|
||||
before = _ledger()
|
||||
r = client.post(
|
||||
"/api/v1/wallet/withdraw",
|
||||
json={"amount_cents": 200, "source": "coin_cash"},
|
||||
# 50 分 = 0.5 元档(7-9 起 coin_cash 只能提预设档位)
|
||||
json={"amount_cents": 50, "source": "coin_cash"},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""福利页(coin_cash)提现档位规则测试(7-9提现ui对齐)。
|
||||
|
||||
规则(2026-07-09 拍板):
|
||||
- 档位 0.1/0.3(新人,历史一次性,免广告)+ 0.5(日3次)/10/20(日1次)。
|
||||
- 计次口径"发起就算":当天创建的单不论最终状态(含被拒)都占名额;新人档任何状态都算用过。
|
||||
- 常规三档每天只能选一个;新人档不参与该互斥,两个新人档同天可各提一次。
|
||||
- invite_cash 无档位概念:tiers 为空、下单不走档位闸(邀请页行为不变)。
|
||||
wxpay 调用全部 monkeypatch;现金余额 DB 直灌(同 test_withdraw.py 套路)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.user import User
|
||||
from app.models.wallet import CoinAccount
|
||||
from app.repositories import wallet as crud_wallet
|
||||
|
||||
|
||||
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 _auth(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _seed_balances(client, token: str, phone: str, cash: int = 0, invite_cash: int = 0) -> None:
|
||||
client.get("/api/v1/wallet/account", headers=_auth(token))
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = db.execute(select(User).where(User.phone == phone)).scalar_one()
|
||||
acc = db.get(CoinAccount, user.id)
|
||||
acc.cash_balance_cents = cash
|
||||
acc.invite_cash_balance_cents = invite_cash
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _patch_userinfo(monkeypatch, openid: str) -> None:
|
||||
monkeypatch.setattr(
|
||||
"app.integrations.wxpay.code_to_userinfo",
|
||||
lambda code: {"openid": openid, "nickname": "昵称", "avatar_url": None, "raw": {}},
|
||||
)
|
||||
|
||||
|
||||
def _reject(bill: str) -> None:
|
||||
"""管理员拒绝:退款结清活跃单(便于同用户继续发起下一笔;名额按'发起就算'仍占)。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
crud_wallet.reject_withdraw(db, bill, "test")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _withdraw(client, token: str, cents: int):
|
||||
return client.post(
|
||||
"/api/v1/wallet/withdraw",
|
||||
json={"amount_cents": cents, "source": "coin_cash"},
|
||||
headers=_auth(token),
|
||||
)
|
||||
|
||||
|
||||
def _tiers(client, token: str, source: str = "coin_cash") -> list[dict]:
|
||||
r = client.get(
|
||||
"/api/v1/wallet/withdraw-info", params={"source": source}, headers=_auth(token)
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["tiers"]
|
||||
|
||||
|
||||
def test_withdraw_info_tiers_full_and_invite_empty(client, monkeypatch) -> None:
|
||||
"""新用户 coin_cash 下发 5 档(新人角标齐);invite_cash tiers 为空。"""
|
||||
token = _login(client, "13800006001")
|
||||
tiers = _tiers(client, token)
|
||||
assert [t["amount_cents"] for t in tiers] == [10, 30, 50, 1000, 2000]
|
||||
assert [t["label"] for t in tiers] == ["0.1", "0.3", "0.5", "10", "20"]
|
||||
assert tiers[0]["badge"] == "新人福利" and tiers[0]["is_newbie"] is True
|
||||
assert tiers[1]["badge"] == "新人福利" and tiers[1]["is_newbie"] is True
|
||||
assert all(t["available"] for t in tiers)
|
||||
assert tiers[2]["remaining_today"] == 3 # 0.5 日 3 次
|
||||
assert _tiers(client, token, source="invite_cash") == []
|
||||
|
||||
|
||||
def test_newbie_tiers_independent_and_once_forever(client, monkeypatch) -> None:
|
||||
"""0.1 提过(即使被拒)→ 永久消失;同天 0.3 仍可提;新人档不锁常规档。"""
|
||||
_patch_userinfo(monkeypatch, "openid_tier_2")
|
||||
token = _login(client, "13800006002")
|
||||
_seed_balances(client, token, "13800006002", cash=5000)
|
||||
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
||||
|
||||
r = _withdraw(client, token, 10) # 0.1 新人档
|
||||
assert r.status_code == 200, r.text
|
||||
_reject(r.json()["out_bill_no"]) # 被拒也算用过("发起就算")
|
||||
|
||||
tiers = _tiers(client, token)
|
||||
amounts = [t["amount_cents"] for t in tiers]
|
||||
assert 10 not in amounts # 0.1 消失
|
||||
assert 30 in amounts # 0.3 还在,同天仍可提
|
||||
# 新人档不参与"选一个额度":常规三档全部仍可提
|
||||
regular = {t["amount_cents"]: t for t in tiers if not t["is_newbie"]}
|
||||
assert all(regular[a]["available"] for a in (50, 1000, 2000))
|
||||
|
||||
r = _withdraw(client, token, 30) # 同天 0.3 照提
|
||||
assert r.status_code == 200, r.text
|
||||
_reject(r.json()["out_bill_no"])
|
||||
|
||||
# 0.1 已用过,重提 → 非法金额(档位已消失)
|
||||
r = _withdraw(client, token, 10)
|
||||
assert r.status_code == 400, r.text
|
||||
|
||||
|
||||
def test_regular_daily_select_one_tier(client, monkeypatch) -> None:
|
||||
"""当天提过 0.5 → 10/20 置灰 other_tier_selected,下单 409;0.5 还能继续提(3 次内)。"""
|
||||
_patch_userinfo(monkeypatch, "openid_tier_3")
|
||||
token = _login(client, "13800006003")
|
||||
_seed_balances(client, token, "13800006003", cash=10_000)
|
||||
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
||||
|
||||
r = _withdraw(client, token, 50)
|
||||
assert r.status_code == 200, r.text
|
||||
_reject(r.json()["out_bill_no"]) # 结清活跃单;当天名额仍占("发起就算")
|
||||
|
||||
tiers = {t["amount_cents"]: t for t in _tiers(client, token)}
|
||||
assert tiers[50]["available"] and tiers[50]["remaining_today"] == 2
|
||||
assert not tiers[1000]["available"] and tiers[1000]["disabled_reason"] == "other_tier_selected"
|
||||
assert not tiers[2000]["available"] and tiers[2000]["disabled_reason"] == "other_tier_selected"
|
||||
|
||||
r = _withdraw(client, token, 1000) # 选一额度互斥 → 409
|
||||
assert r.status_code == 409, r.text
|
||||
assert "今日额度已达上限" in r.json()["detail"]
|
||||
|
||||
r = _withdraw(client, token, 50) # 0.5 第 2 次照常
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_regular_daily_quota_exhausted(client, monkeypatch) -> None:
|
||||
"""0.5 日 3 次:第 4 次 409;tiers 显示 quota_exhausted。"""
|
||||
_patch_userinfo(monkeypatch, "openid_tier_4")
|
||||
token = _login(client, "13800006004")
|
||||
_seed_balances(client, token, "13800006004", cash=10_000)
|
||||
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
||||
|
||||
for _ in range(3):
|
||||
r = _withdraw(client, token, 50)
|
||||
assert r.status_code == 200, r.text
|
||||
_reject(r.json()["out_bill_no"])
|
||||
|
||||
tiers = {t["amount_cents"]: t for t in _tiers(client, token)}
|
||||
assert not tiers[50]["available"]
|
||||
assert tiers[50]["disabled_reason"] == "quota_exhausted"
|
||||
assert tiers[50]["remaining_today"] == 0
|
||||
|
||||
r = _withdraw(client, token, 50)
|
||||
assert r.status_code == 409, r.text
|
||||
|
||||
|
||||
def test_non_tier_amount_rejected_for_coin_cash(client, monkeypatch) -> None:
|
||||
"""coin_cash 只能提预设档位:任意其他金额 400(防绕过客户端刷)。"""
|
||||
_patch_userinfo(monkeypatch, "openid_tier_5")
|
||||
token = _login(client, "13800006005")
|
||||
_seed_balances(client, token, "13800006005", cash=10_000)
|
||||
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
||||
|
||||
r = _withdraw(client, token, 123)
|
||||
assert r.status_code == 400, r.text
|
||||
|
||||
|
||||
def test_invite_cash_not_gated_by_tiers(client, monkeypatch) -> None:
|
||||
"""invite_cash 不走档位闸:非档位金额(200=2元)照常下单——邀请页行为不变。"""
|
||||
_patch_userinfo(monkeypatch, "openid_tier_6")
|
||||
token = _login(client, "13800006006")
|
||||
_seed_balances(client, token, "13800006006", invite_cash=500)
|
||||
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
||||
|
||||
r = client.post(
|
||||
"/api/v1/wallet/withdraw",
|
||||
json={"amount_cents": 200, "source": "invite_cash"},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["status"] == "reviewing"
|
||||
Reference in New Issue
Block a user