Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b01b251f83 | |||
| a2fd666203 | |||
| aa898fc09a | |||
| 2472cfc44a | |||
| c958546e07 | |||
| 4ee6de2548 | |||
| c78cfc838f | |||
| a4c0fa0de6 | |||
| d55f47fc59 | |||
| 7368a1ca8a | |||
| a173e07c5f | |||
| 3a40f617bd | |||
| 003fd9d986 | |||
| d3e3516ce9 | |||
| 599432cd05 |
@@ -30,6 +30,7 @@ data/*
|
||||
!data/media/
|
||||
data/media/*
|
||||
!data/media/dl.html
|
||||
!data/media/taobao_landing.jpg
|
||||
|
||||
secrets/*
|
||||
!secrets/.gitkeep
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""comparison_record: 客户端环境 / 性能统计 / LLM 明细 debug 字段
|
||||
|
||||
Revision ID: comparison_debug_fields
|
||||
Revises: cps_activity_image
|
||||
Create Date: 2026-06-17 22:00:00.000000
|
||||
|
||||
给 admin 比价记录 debug 页新增采集列,全部 nullable(旧记录 / 未发版客户端为 NULL,向后兼容):
|
||||
- 客户端环境: device_*/rom_*/android_*/app_version*/source_app_version/经纬度
|
||||
- 过程统计: total_ms/step_count/llm_call_count/retry_count
|
||||
- LLM 明细: llm_calls(JSONB,server 按 trace_id 同机拉 pricebot 落库)
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision: str = "comparison_debug_fields"
|
||||
down_revision: Union[str, Sequence[str], None] = "cps_activity_image"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_JSONB = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("comparison_record", sa.Column("device_model", sa.String(64), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("device_manufacturer", sa.String(64), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("rom_vendor", sa.String(32), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("rom_name", sa.String(32), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("rom_version", sa.Integer(), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("android_version", sa.String(16), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("android_sdk", sa.Integer(), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("app_version", sa.String(32), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("app_version_code", sa.Integer(), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("source_app_version", sa.String(32), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("longitude", sa.Float(), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("latitude", sa.Float(), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("total_ms", sa.Integer(), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("step_count", sa.Integer(), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("llm_call_count", sa.Integer(), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("retry_count", sa.Integer(), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("llm_calls", _JSONB, nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for col in (
|
||||
"llm_calls", "retry_count", "llm_call_count", "step_count", "total_ms",
|
||||
"latitude", "longitude", "source_app_version", "app_version_code",
|
||||
"app_version", "android_sdk", "android_version", "rom_version",
|
||||
"rom_name", "rom_vendor", "device_manufacturer", "device_model",
|
||||
):
|
||||
op.drop_column("comparison_record", col)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""cps activity 落地页图: cps_activity.image_url + 回填存量淘宝活动
|
||||
|
||||
新建淘宝活动时运营提供落地页主视觉图(上传或选已有),落地页按活动展示对应图。
|
||||
存量淘宝活动回填为此前写死的那张图,无缝衔接。
|
||||
|
||||
Revision ID: cps_activity_image
|
||||
Revises: cps_v2_platforms
|
||||
Create Date: 2026-06-17 12:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
revision: str = "cps_activity_image"
|
||||
down_revision: Union[str, Sequence[str], None] = "cps_v2_platforms"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("cps_activity", sa.Column("image_url", sa.String(512), nullable=True))
|
||||
# 回填存量淘宝活动 = 此前写死的那张图(绝对 URL,基于落地页域名 CPS_REDIRECT_BASE;
|
||||
# dev 未配则相对路径)。新活动由运营自传/选,不回填。
|
||||
base = settings.CPS_REDIRECT_BASE.rstrip("/")
|
||||
default_img = f"{base}/media/taobao_landing.jpg" if base else "/media/taobao_landing.jpg"
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE cps_activity SET image_url = :img WHERE platform = 'taobao'"
|
||||
).bindparams(img=default_img)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("cps_activity", "image_url")
|
||||
@@ -0,0 +1,75 @@
|
||||
"""cps v2: 活动 payload(淘口令/京东链接) + 群多平台/sid 可空 + 点击 event_type
|
||||
|
||||
Revision ID: cps_v2_platforms
|
||||
Revises: cps_link_tables
|
||||
Create Date: 2026-06-17 11:00:00.000000
|
||||
|
||||
接入淘宝/京东:
|
||||
- cps_activity.payload 淘宝整段淘口令文本 / 京东推广链接(美团仍用 act_id)
|
||||
- cps_group.platforms 该群发哪些平台(多选);sid 改可空(纯淘宝/京东无 sid)
|
||||
- cps_link.sid 可空 + target_url 加长(淘口令较长)
|
||||
- cps_click.event_type visit / copy(淘宝落地页"复制口令")
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision: str = "cps_v2_platforms"
|
||||
down_revision: Union[str, Sequence[str], None] = "cps_link_tables"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 活动:淘宝淘口令 / 京东链接
|
||||
op.add_column("cps_activity", sa.Column("payload", sa.Text(), nullable=True))
|
||||
|
||||
# 群:多平台(现有群都是美团,server_default 回填)+ sid 可空
|
||||
# server_default 按方言区分:Postgres 用 ::jsonb 转换;SQLite(本地)用纯 JSON 文本字面量
|
||||
#(SQLite 不认 ::jsonb,会报 unrecognized token: ":")。
|
||||
bind = op.get_bind()
|
||||
platforms_default = (
|
||||
sa.text("'[\"meituan\"]'::jsonb")
|
||||
if bind.dialect.name == "postgresql"
|
||||
else sa.text("'[\"meituan\"]'")
|
||||
)
|
||||
# alter_column 在 SQLite 原生不支持 ALTER COLUMN,必须走 batch(重建表);Postgres 下 batch 直接 ALTER。
|
||||
# 用 batch_alter_table 跨方言通吃(render_as_batch 只管 autogenerate 渲染,不会自动包裹手写的 alter_column)。
|
||||
with op.batch_alter_table("cps_group") as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"platforms",
|
||||
sa.JSON().with_variant(postgresql.JSONB(), "postgresql"),
|
||||
nullable=False,
|
||||
server_default=platforms_default,
|
||||
)
|
||||
)
|
||||
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=True)
|
||||
|
||||
# link:sid 可空 + target 加长
|
||||
with op.batch_alter_table("cps_link") as batch_op:
|
||||
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=True)
|
||||
batch_op.alter_column("target_url", existing_type=sa.String(1024), type_=sa.String(2048))
|
||||
|
||||
# click:sid 可空 + 事件类型
|
||||
with op.batch_alter_table("cps_click") as batch_op:
|
||||
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=True)
|
||||
batch_op.add_column(
|
||||
sa.Column("event_type", sa.String(16), nullable=False, server_default="visit")
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("cps_click") as batch_op:
|
||||
batch_op.drop_column("event_type")
|
||||
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=False)
|
||||
with op.batch_alter_table("cps_link") as batch_op:
|
||||
batch_op.alter_column("target_url", existing_type=sa.String(2048), type_=sa.String(1024))
|
||||
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=False)
|
||||
with op.batch_alter_table("cps_group") as batch_op:
|
||||
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=False)
|
||||
batch_op.drop_column("platforms")
|
||||
op.drop_column("cps_activity", "payload")
|
||||
@@ -18,6 +18,7 @@ from app.admin.routers.ad_revenue import router as ad_revenue_router
|
||||
from app.admin.routers.admins import router as admins_router
|
||||
from app.admin.routers.audit import router as audit_router
|
||||
from app.admin.routers.auth import router as auth_router
|
||||
from app.admin.routers.comparison import router as comparison_router
|
||||
from app.admin.routers.config import router as config_router
|
||||
from app.admin.routers.cps import router as cps_router
|
||||
from app.admin.routers.dashboard import router as dashboard_router
|
||||
@@ -91,6 +92,7 @@ admin_app.include_router(feedback_router)
|
||||
admin_app.include_router(admins_router)
|
||||
admin_app.include_router(audit_router)
|
||||
admin_app.include_router(config_router)
|
||||
admin_app.include_router(comparison_router)
|
||||
admin_app.include_router(cps_router)
|
||||
admin_app.include_router(ad_audit_router)
|
||||
admin_app.include_router(ad_revenue_router)
|
||||
|
||||
+182
-42
@@ -5,7 +5,7 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -17,12 +17,16 @@ from app.integrations import meituan
|
||||
from app.repositories import cps_link as cps_link_repo
|
||||
from app.models.cps_activity import CpsActivity
|
||||
from app.models.cps_group import CpsGroup
|
||||
from app.models.cps_link import CpsClick
|
||||
from app.models.cps_order import CpsOrder
|
||||
|
||||
# 美团订单状态:取消(4)/风控(5)不计佣金;结算(6)为佣金真正到账
|
||||
_INVALID_STATUS = {"4", "5"}
|
||||
_SETTLED_STATUS = "6"
|
||||
|
||||
# CPS 点击时序按北京时区分桶(运营看的是北京时间)
|
||||
_BJ_TZ = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
# ───────────── 单位换算 ─────────────
|
||||
def _yuan_to_cents(v: object) -> int | None:
|
||||
@@ -71,19 +75,23 @@ def list_groups(
|
||||
|
||||
|
||||
def create_group(
|
||||
db: Session, *, name: str, sid: str | None = None,
|
||||
db: Session, *, name: str, platforms: list[str], sid: str | None = None,
|
||||
member_count: int | None = None, remark: str | None = None, commit: bool = True,
|
||||
) -> CpsGroup:
|
||||
"""建群。sid 留空 → 自动 g<id>(先 flush 拿 id 再回填)。"""
|
||||
"""建群。含 meituan 才有 sid(留空自动 g<id>);纯淘宝/京东 sid=None(那俩不支持 sid)。"""
|
||||
has_meituan = "meituan" in (platforms or [])
|
||||
# 含美团:用填的 sid,或临时占位待回填 g<id>;不含美团:sid 恒 None
|
||||
seed_sid = (sid or f"tmp{uuid4().hex[:20]}") if has_meituan else None
|
||||
group = CpsGroup(
|
||||
name=name,
|
||||
sid=sid or f"tmp{uuid4().hex[:20]}", # 临时唯一占位,留空时下面回填 g<id>
|
||||
platforms=list(platforms or []),
|
||||
sid=seed_sid,
|
||||
member_count=member_count,
|
||||
remark=remark,
|
||||
)
|
||||
db.add(group)
|
||||
db.flush()
|
||||
if not sid:
|
||||
if has_meituan and not sid:
|
||||
group.sid = f"g{group.id}"
|
||||
db.flush()
|
||||
if commit:
|
||||
@@ -94,11 +102,14 @@ def create_group(
|
||||
|
||||
def update_group(
|
||||
db: Session, group: CpsGroup, *, name: str | None = None,
|
||||
platforms: list[str] | None = None,
|
||||
member_count: int | None = None, status: str | None = None,
|
||||
remark: str | None = None, commit: bool = True,
|
||||
) -> CpsGroup:
|
||||
if name is not None:
|
||||
group.name = name
|
||||
if platforms is not None:
|
||||
group.platforms = list(platforms)
|
||||
if member_count is not None:
|
||||
group.member_count = member_count
|
||||
if status is not None:
|
||||
@@ -113,6 +124,15 @@ def update_group(
|
||||
return group
|
||||
|
||||
|
||||
def delete_group(db: Session, group: CpsGroup, *, commit: bool = True) -> None:
|
||||
"""硬删群。不级联删已生成的 cps_link(已发链接继续可用);该群历史点击/订单按 sid 仍在库。"""
|
||||
db.delete(group)
|
||||
if commit:
|
||||
db.commit()
|
||||
else:
|
||||
db.flush()
|
||||
|
||||
|
||||
# ───────────── 活动 ─────────────
|
||||
def get_activity(db: Session, activity_id: int) -> CpsActivity | None:
|
||||
return db.get(CpsActivity, activity_id)
|
||||
@@ -130,13 +150,29 @@ def list_activities(
|
||||
return offset_paginate(db, stmt, (desc(CpsActivity.id),), limit=limit, cursor=cursor)
|
||||
|
||||
|
||||
def list_activity_images(db: Session) -> list[str]:
|
||||
"""已用过的活动落地页图(distinct 非空 image_url),供新建活动复用选择。"""
|
||||
rows = (
|
||||
db.execute(
|
||||
select(CpsActivity.image_url)
|
||||
.where(CpsActivity.image_url.is_not(None))
|
||||
.distinct()
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return [r for r in rows if r]
|
||||
|
||||
|
||||
def create_activity(
|
||||
db: Session, *, name: str, platform: str = "meituan", act_id: str | None = None,
|
||||
product_view_sign: str | None = None, remark: str | None = None, commit: bool = True,
|
||||
product_view_sign: str | None = None, payload: str | None = None,
|
||||
image_url: str | None = None, remark: str | None = None, commit: bool = True,
|
||||
) -> CpsActivity:
|
||||
activity = CpsActivity(
|
||||
name=name, platform=platform, act_id=act_id,
|
||||
product_view_sign=product_view_sign, remark=remark,
|
||||
product_view_sign=product_view_sign, payload=payload,
|
||||
image_url=image_url, remark=remark,
|
||||
)
|
||||
db.add(activity)
|
||||
if commit:
|
||||
@@ -147,6 +183,46 @@ def create_activity(
|
||||
return activity
|
||||
|
||||
|
||||
def update_activity(
|
||||
db: Session, activity: CpsActivity, *, name: str | None = None,
|
||||
platform: str | None = None, act_id: str | None = None,
|
||||
product_view_sign: str | None = None, payload: str | None = None,
|
||||
image_url: str | None = None, remark: str | None = None,
|
||||
status: str | None = None, commit: bool = True,
|
||||
) -> CpsActivity:
|
||||
if name is not None:
|
||||
activity.name = name
|
||||
if platform is not None:
|
||||
activity.platform = platform
|
||||
if act_id is not None:
|
||||
activity.act_id = act_id
|
||||
if product_view_sign is not None:
|
||||
activity.product_view_sign = product_view_sign
|
||||
if payload is not None:
|
||||
activity.payload = payload
|
||||
if image_url is not None:
|
||||
activity.image_url = image_url
|
||||
if remark is not None:
|
||||
activity.remark = remark
|
||||
if status is not None:
|
||||
activity.status = status
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(activity)
|
||||
else:
|
||||
db.flush()
|
||||
return activity
|
||||
|
||||
|
||||
def delete_activity(db: Session, activity: CpsActivity, *, commit: bool = True) -> None:
|
||||
"""硬删活动。不级联删 cps_link(已发链接继续可用;淘宝落地页缺活动时取图走兜底默认图)。"""
|
||||
db.delete(activity)
|
||||
if commit:
|
||||
db.commit()
|
||||
else:
|
||||
db.flush()
|
||||
|
||||
|
||||
# ───────────── 订单(对账) ─────────────
|
||||
def _map_order_fields(r: dict) -> dict:
|
||||
"""美团 query_order 单条 dataList → CpsOrder 字段(金额转分、时间转 datetime)。"""
|
||||
@@ -233,55 +309,119 @@ def group_stats(
|
||||
"""按 sid 聚合订单 + join 群信息。已建但本期无单的活跃群也列出(全 0)。
|
||||
未归群的 sid(历史/其它来源)单独成行 group_id=None。按预估佣金降序。
|
||||
"""
|
||||
stmt = select(CpsOrder)
|
||||
ostmt = select(CpsOrder)
|
||||
if date_from is not None:
|
||||
stmt = stmt.where(CpsOrder.pay_time >= _as_utc(date_from))
|
||||
ostmt = ostmt.where(CpsOrder.pay_time >= _as_utc(date_from))
|
||||
if date_to is not None:
|
||||
stmt = stmt.where(CpsOrder.pay_time <= _as_utc(date_to))
|
||||
orders = list(db.execute(stmt).scalars().all())
|
||||
ostmt = ostmt.where(CpsOrder.pay_time <= _as_utc(date_to))
|
||||
orders_by_sid: dict[str | None, list[CpsOrder]] = {}
|
||||
for o in db.execute(ostmt).scalars().all():
|
||||
orders_by_sid.setdefault(o.sid, []).append(o)
|
||||
|
||||
groups = {g.sid: g for g in db.execute(select(CpsGroup)).scalars().all()}
|
||||
clicks = cps_link_repo.click_stats_by_group(db, date_from=date_from, date_to=date_to)
|
||||
groups = list(db.execute(select(CpsGroup)).scalars().all())
|
||||
|
||||
buckets: dict[str | None, list[CpsOrder]] = {}
|
||||
for o in orders:
|
||||
buckets.setdefault(o.sid, []).append(o)
|
||||
|
||||
rows: list[dict] = []
|
||||
for sid, items in buckets.items():
|
||||
g = groups.get(sid) if sid else None
|
||||
def _order_agg(items: list[CpsOrder]) -> dict:
|
||||
valid = [o for o in items if o.mt_status not in _INVALID_STATUS]
|
||||
settled = [o for o in items if o.mt_status == _SETTLED_STATUS]
|
||||
canceled = [o for o in items if o.mt_status in _INVALID_STATUS]
|
||||
rows.append({
|
||||
"group_id": g.id if g else None,
|
||||
"sid": sid,
|
||||
"name": g.name if g else (sid or "(无 sid 归属)"),
|
||||
"member_count": g.member_count if g else None,
|
||||
return {
|
||||
"order_count": len(valid),
|
||||
"settled_count": len(settled),
|
||||
"canceled_count": len(canceled),
|
||||
"gmv_cents": sum(o.pay_price_cents or 0 for o in valid),
|
||||
"est_commission_cents": sum(o.commission_cents or 0 for o in valid),
|
||||
"settled_commission_cents": sum(o.commission_cents or 0 for o in settled),
|
||||
})
|
||||
}
|
||||
|
||||
# 已建的活跃群但本期无单 → 补 0 行,让运营看到全部群
|
||||
seen = set(buckets.keys())
|
||||
for sid, g in groups.items():
|
||||
if sid not in seen and g.status == "active":
|
||||
rows.append({
|
||||
"group_id": g.id, "sid": sid, "name": g.name,
|
||||
"member_count": g.member_count, "order_count": 0,
|
||||
"settled_count": 0, "canceled_count": 0, "gmv_cents": 0,
|
||||
"est_commission_cents": 0, "settled_commission_cents": 0,
|
||||
})
|
||||
# 淘宝/京东无法对账 → 对账字段全 None(前端显示 "-")
|
||||
_no_recon = {
|
||||
"order_count": None, "settled_count": None, "canceled_count": None,
|
||||
"gmv_cents": None, "est_commission_cents": None, "settled_commission_cents": None,
|
||||
}
|
||||
|
||||
# 合并点击数据(按 group_id):点击只对生成过我们短链的群有,未归群行(group_id=None)恒 0
|
||||
clicks = cps_link_repo.click_stats_by_group(db, date_from=date_from, date_to=date_to)
|
||||
for r in rows:
|
||||
c = clicks.get(r["group_id"]) if r["group_id"] is not None else None
|
||||
r["click_pv"] = c["pv"] if c else 0
|
||||
r["click_uv"] = c["uv"] if c else 0
|
||||
rows: list[dict] = []
|
||||
seen_sids: set[str] = set()
|
||||
for g in groups:
|
||||
if g.status != "active":
|
||||
continue
|
||||
c = clicks.get(g.id) or {}
|
||||
row = {
|
||||
"group_id": g.id, "sid": g.sid, "name": g.name,
|
||||
"platforms": list(g.platforms or []), "member_count": g.member_count,
|
||||
"click_pv": c.get("pv", 0), "click_uv": c.get("uv", 0),
|
||||
"copy_pv": c.get("copy_pv", 0), "copy_uv": c.get("copy_uv", 0),
|
||||
}
|
||||
# 对账只对美团群(有 sid);淘宝/京东 → None
|
||||
if "meituan" in (g.platforms or []) and g.sid:
|
||||
row.update(_order_agg(orders_by_sid.get(g.sid, [])))
|
||||
seen_sids.add(g.sid)
|
||||
else:
|
||||
row.update(_no_recon)
|
||||
rows.append(row)
|
||||
|
||||
rows.sort(key=lambda x: x["est_commission_cents"], reverse=True)
|
||||
# 未归群的历史美团 sid(如 wonderableai):单列,有对账无点击
|
||||
for sid, items in orders_by_sid.items():
|
||||
if sid is None or sid in seen_sids:
|
||||
continue
|
||||
row = {
|
||||
"group_id": None, "sid": sid, "name": sid, "platforms": ["meituan"],
|
||||
"member_count": None, "click_pv": 0, "click_uv": 0, "copy_pv": 0, "copy_uv": 0,
|
||||
}
|
||||
row.update(_order_agg(items))
|
||||
rows.append(row)
|
||||
|
||||
# 佣金降序(None 当 0),次按点击
|
||||
rows.sort(key=lambda x: ((x["est_commission_cents"] or 0), x["click_pv"]), reverse=True)
|
||||
return rows
|
||||
|
||||
|
||||
def group_click_timeseries(
|
||||
db: Session, *, group_id: int, granularity: str,
|
||||
start: datetime, end: datetime,
|
||||
) -> list[dict]:
|
||||
"""该群点击时序(北京时区分桶,granularity=day|hour)。
|
||||
|
||||
每桶:click_pv/uv(visit)+copy_pv/uv;UV=distinct(ip,ua)。**生成连续桶(无数据补 0)**,
|
||||
让折线图 x 轴按所选范围铺满、不断裂(CPS 数据稀疏,补零是关键)。
|
||||
"""
|
||||
rows = db.execute(
|
||||
select(CpsClick.clicked_at, CpsClick.event_type, CpsClick.ip, CpsClick.ua)
|
||||
.where(CpsClick.group_id == group_id)
|
||||
.where(CpsClick.clicked_at >= _as_utc(start))
|
||||
.where(CpsClick.clicked_at <= _as_utc(end))
|
||||
).all()
|
||||
|
||||
agg: dict = {} # bucket_key -> {vp, vu(set), cp, cu(set)}
|
||||
for clicked_at, event_type, ip, ua in rows:
|
||||
bj = clicked_at.astimezone(_BJ_TZ)
|
||||
key = bj.strftime("%Y-%m-%d") if granularity == "day" else bj.hour
|
||||
b = agg.setdefault(key, {"vp": 0, "vu": set(), "cp": 0, "cu": set()})
|
||||
uid = f"{ip or ''}|{ua or ''}"
|
||||
if event_type == "copy":
|
||||
b["cp"] += 1
|
||||
b["cu"].add(uid)
|
||||
else:
|
||||
b["vp"] += 1
|
||||
b["vu"].add(uid)
|
||||
|
||||
def _point(label: str, b: dict | None) -> dict:
|
||||
return {
|
||||
"label": label,
|
||||
"click_pv": b["vp"] if b else 0,
|
||||
"click_uv": len(b["vu"]) if b else 0,
|
||||
"copy_pv": b["cp"] if b else 0,
|
||||
"copy_uv": len(b["cu"]) if b else 0,
|
||||
}
|
||||
|
||||
points: list[dict] = []
|
||||
if granularity == "day":
|
||||
cur = start.astimezone(_BJ_TZ).date()
|
||||
last = end.astimezone(_BJ_TZ).date()
|
||||
while cur <= last:
|
||||
points.append(_point(cur.strftime("%m-%d"), agg.get(cur.strftime("%Y-%m-%d"))))
|
||||
cur += timedelta(days=1)
|
||||
else:
|
||||
for h in range(24):
|
||||
points.append(_point(f"{h:02d}:00", agg.get(h)))
|
||||
return points
|
||||
|
||||
@@ -109,6 +109,63 @@ def list_users(
|
||||
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
|
||||
|
||||
|
||||
def _attach_user_info(db: Session, records: list[ComparisonRecord]) -> None:
|
||||
"""给每条比价记录瞬态挂 phone/nickname(非 DB 列,供 admin schema from_attributes 读)。"""
|
||||
uids = {r.user_id for r in records}
|
||||
if not uids:
|
||||
return
|
||||
rows = db.execute(
|
||||
select(User.id, User.phone, User.nickname).where(User.id.in_(uids))
|
||||
).all()
|
||||
umap = {uid: (phone, nick) for uid, phone, nick in rows}
|
||||
for r in records:
|
||||
phone, nick = umap.get(r.user_id, (None, None))
|
||||
r.phone = phone
|
||||
r.nickname = nick
|
||||
|
||||
|
||||
def list_comparison_records(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: int | None = None,
|
||||
phone: str | None = None,
|
||||
status: str | None = None,
|
||||
business_type: str | None = None,
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[ComparisonRecord], int | None, int]:
|
||||
"""admin 比价记录列表(debug)。按 user_id 精确 或 phone 前缀定位用户 + status/业务类型筛,
|
||||
offset 分页(创建时间倒序、id 兜底)。join User 取 phone/nickname 瞬态挂记录上。"""
|
||||
stmt = select(ComparisonRecord)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(ComparisonRecord.user_id == user_id)
|
||||
if phone:
|
||||
stmt = stmt.where(
|
||||
ComparisonRecord.user_id.in_(
|
||||
select(User.id).where(User.phone.like(f"{phone}%"))
|
||||
)
|
||||
)
|
||||
if status:
|
||||
stmt = stmt.where(ComparisonRecord.status == status)
|
||||
if business_type:
|
||||
stmt = stmt.where(ComparisonRecord.business_type == business_type)
|
||||
items, next_cursor, total = offset_paginate(
|
||||
db, stmt,
|
||||
(desc(ComparisonRecord.created_at), desc(ComparisonRecord.id)),
|
||||
limit=limit, cursor=cursor,
|
||||
)
|
||||
_attach_user_info(db, items)
|
||||
return items, next_cursor, total
|
||||
|
||||
|
||||
def get_comparison_record(db: Session, record_id: int) -> ComparisonRecord | None:
|
||||
"""admin 取单条比价记录(任意用户,不限本人;附 phone/nickname 瞬态)。"""
|
||||
rec = db.get(ComparisonRecord, record_id)
|
||||
if rec is not None:
|
||||
_attach_user_info(db, [rec])
|
||||
return rec
|
||||
|
||||
|
||||
def list_onboarding_devices(db: Session, *, limit: int = 500) -> list[dict]:
|
||||
"""按设备(device_id, ANDROID_ID)聚合 onboarding_completion:每台设备走过引导的账号数 +
|
||||
最近完成时间,按最近完成倒序。设备维度新手引导管理用。没走过引导的设备不在表里(本就会
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""admin 比价记录 debug:按 user_id / phone 查列表 + 单条详情(含 LLM 每次调用明细 / 原始 payload)。
|
||||
|
||||
只读。读权限默认即可(get_current_admin),不额外 require_role(查记录是基础 debug 能力)。
|
||||
trace_url 无条件下发——admin 是内部 debug 工具,不走 C 端 user.debug_trace_enabled 权限闸。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from app.admin.deps import AdminDb, get_current_admin
|
||||
from app.admin.repositories import queries
|
||||
from app.admin.schemas.common import CursorPage
|
||||
from app.admin.schemas.comparison import AdminComparisonDetail, AdminComparisonListItem
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/comparison-records",
|
||||
tags=["admin-comparison"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=CursorPage[AdminComparisonListItem],
|
||||
summary="比价记录列表(按 user_id/phone 筛 + 分页)",
|
||||
)
|
||||
def list_comparison_records(
|
||||
db: AdminDb,
|
||||
user_id: Annotated[int | None, Query()] = None,
|
||||
phone: Annotated[str | None, Query(description="手机号前缀")] = None,
|
||||
status: Annotated[str | None, Query(pattern="^(success|failed)$")] = None,
|
||||
business_type: Annotated[str | None, Query()] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[AdminComparisonListItem]:
|
||||
items, next_cursor, total = queries.list_comparison_records(
|
||||
db, user_id=user_id, phone=phone, status=status,
|
||||
business_type=business_type, limit=limit, cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
items=[AdminComparisonListItem.model_validate(r) for r in items],
|
||||
next_cursor=next_cursor,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{record_id}",
|
||||
response_model=AdminComparisonDetail,
|
||||
summary="比价记录详情(含 LLM 每次调用明细 + 原始 payload)",
|
||||
)
|
||||
def get_comparison_record(record_id: int, db: AdminDb) -> AdminComparisonDetail:
|
||||
rec = queries.get_comparison_record(db, record_id)
|
||||
if rec is None:
|
||||
raise HTTPException(status_code=404, detail="记录不存在")
|
||||
return AdminComparisonDetail.model_validate(rec)
|
||||
+237
-70
@@ -1,6 +1,7 @@
|
||||
"""admin CPS 分发与对账:群(sid)管理 + 活动池 + 生成带 sid 券链接 + 美团订单对账 + 统计。
|
||||
"""admin CPS 分发与对账:群/活动管理 + 生成落地页短链 + 美团订单对账 + 统计。
|
||||
|
||||
当前仅接美团(get_referral_link / query_order)。淘宝/京东待各自联盟凭证。
|
||||
平台:meituan(actId+sid 转链 + query_order 对账) / taobao(整段淘口令) / jd(链接)。
|
||||
淘宝/京东无 API → 只统计点击(咱落地页 PV/UV + 淘宝复制),对账字段显示 "-"。
|
||||
群/活动管理 = operator;订单对账(涉佣金) = finance;只读列表/统计 = 登录即可。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -9,30 +10,35 @@ import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
|
||||
|
||||
from app.admin.audit import write_audit
|
||||
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
||||
from app.admin.repositories import cps as cps_repo
|
||||
from app.admin.schemas.common import CursorPage
|
||||
from app.core.config import settings
|
||||
from app.repositories import cps_link as cps_link_repo
|
||||
from app.admin.schemas.cps import (
|
||||
CpsActivityCreate,
|
||||
CpsActivityOut,
|
||||
CpsActivityUpdate,
|
||||
CpsGroupCreate,
|
||||
CpsGroupOut,
|
||||
CpsGroupStat,
|
||||
CpsGroupUpdate,
|
||||
CpsOrderOut,
|
||||
CpsReconcileResult,
|
||||
CpsReferralLinkOut,
|
||||
CpsReferralLinkRequest,
|
||||
CpsReferralLinkItem,
|
||||
CpsReferralLinksOut,
|
||||
CpsReferralLinksRequest,
|
||||
CpsStatsOut,
|
||||
)
|
||||
from app.core import media
|
||||
from app.core.config import settings
|
||||
from app.integrations import meituan
|
||||
from app.integrations.meituan import MeituanCpsError
|
||||
from app.models.admin import AdminUser
|
||||
from app.models.cps_activity import CpsActivity
|
||||
from app.models.cps_group import CpsGroup
|
||||
from app.repositories import cps_link as cps_link_repo
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/cps",
|
||||
@@ -40,9 +46,11 @@ router = APIRouter(
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
_VALID_PLATFORMS = {"meituan", "taobao", "jd"}
|
||||
|
||||
# ───────────── 群(sid) ─────────────
|
||||
@router.get("/groups", response_model=CursorPage[CpsGroupOut], summary="群(sid)列表")
|
||||
|
||||
# ───────────── 群 ─────────────
|
||||
@router.get("/groups", response_model=CursorPage[CpsGroupOut], summary="群列表")
|
||||
def list_groups(
|
||||
db: AdminDb,
|
||||
keyword: Annotated[str | None, Query(max_length=100)] = None,
|
||||
@@ -60,22 +68,27 @@ def list_groups(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/groups", response_model=CpsGroupOut, summary="新建群(分配 sid)")
|
||||
@router.post("/groups", response_model=CpsGroupOut, summary="新建群")
|
||||
def create_group(
|
||||
body: CpsGroupCreate,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> CpsGroupOut:
|
||||
if body.sid and cps_repo.get_group_by_sid(db, body.sid) is not None:
|
||||
bad = [p for p in body.platforms if p not in _VALID_PLATFORMS]
|
||||
if bad:
|
||||
raise HTTPException(status_code=400, detail=f"非法平台: {bad}")
|
||||
has_meituan = "meituan" in body.platforms
|
||||
if has_meituan and body.sid and cps_repo.get_group_by_sid(db, body.sid) is not None:
|
||||
raise HTTPException(status_code=409, detail="sid 已存在")
|
||||
group = cps_repo.create_group(
|
||||
db, name=body.name, sid=body.sid, member_count=body.member_count,
|
||||
remark=body.remark, commit=False,
|
||||
db, name=body.name, platforms=body.platforms, sid=body.sid,
|
||||
member_count=body.member_count, remark=body.remark, commit=False,
|
||||
)
|
||||
write_audit(
|
||||
db, admin, action="cps.group.create", target_type="cps_group", target_id=group.id,
|
||||
detail={"sid": group.sid, "name": group.name}, ip=get_client_ip(request), commit=False,
|
||||
detail={"name": group.name, "platforms": group.platforms, "sid": group.sid},
|
||||
ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(group)
|
||||
@@ -93,9 +106,13 @@ def update_group(
|
||||
group = cps_repo.get_group(db, group_id)
|
||||
if group is None:
|
||||
raise HTTPException(status_code=404, detail="群不存在")
|
||||
if body.platforms is not None:
|
||||
bad = [p for p in body.platforms if p not in _VALID_PLATFORMS]
|
||||
if bad:
|
||||
raise HTTPException(status_code=400, detail=f"非法平台: {bad}")
|
||||
cps_repo.update_group(
|
||||
db, group, name=body.name, member_count=body.member_count,
|
||||
status=body.status, remark=body.remark, commit=False,
|
||||
db, group, name=body.name, platforms=body.platforms,
|
||||
member_count=body.member_count, status=body.status, remark=body.remark, commit=False,
|
||||
)
|
||||
write_audit(
|
||||
db, admin, action="cps.group.update", target_type="cps_group", target_id=group_id,
|
||||
@@ -106,6 +123,26 @@ def update_group(
|
||||
return CpsGroupOut.model_validate(group)
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}", summary="删除群")
|
||||
def delete_group(
|
||||
group_id: int,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> dict:
|
||||
group = cps_repo.get_group(db, group_id)
|
||||
if group is None:
|
||||
raise HTTPException(status_code=404, detail="群不存在")
|
||||
write_audit(
|
||||
db, admin, action="cps.group.delete", target_type="cps_group", target_id=group_id,
|
||||
detail={"name": group.name, "sid": group.sid, "platforms": list(group.platforms or [])},
|
||||
ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
cps_repo.delete_group(db, group, commit=False)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ───────────── 活动 ─────────────
|
||||
@router.get("/activities", response_model=CursorPage[CpsActivityOut], summary="活动列表")
|
||||
def list_activities(
|
||||
@@ -132,15 +169,23 @@ def create_activity(
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> CpsActivityOut:
|
||||
if not body.act_id and not body.product_view_sign:
|
||||
raise HTTPException(status_code=400, detail="act_id 或 product_view_sign 必填其一")
|
||||
if body.platform == "meituan":
|
||||
if not body.act_id and not body.product_view_sign:
|
||||
raise HTTPException(status_code=400, detail="美团活动需填 actId 或 productViewSign")
|
||||
else: # taobao / jd
|
||||
if not body.payload:
|
||||
label = "淘口令" if body.platform == "taobao" else "推广链接"
|
||||
raise HTTPException(status_code=400, detail=f"{body.platform} 活动需填{label}")
|
||||
if body.platform == "taobao" and not body.image_url:
|
||||
raise HTTPException(status_code=400, detail="淘宝活动需提供落地页图(上传或选已有)")
|
||||
activity = cps_repo.create_activity(
|
||||
db, name=body.name, platform=body.platform, act_id=body.act_id,
|
||||
product_view_sign=body.product_view_sign, remark=body.remark, commit=False,
|
||||
product_view_sign=body.product_view_sign, payload=body.payload,
|
||||
image_url=media.to_abs_media_url(body.image_url), remark=body.remark, commit=False,
|
||||
)
|
||||
write_audit(
|
||||
db, admin, action="cps.activity.create", target_type="cps_activity", target_id=activity.id,
|
||||
detail={"name": activity.name, "act_id": activity.act_id, "platform": activity.platform},
|
||||
detail={"name": activity.name, "platform": activity.platform},
|
||||
ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
@@ -148,65 +193,150 @@ def create_activity(
|
||||
return CpsActivityOut.model_validate(activity)
|
||||
|
||||
|
||||
# ───────────── 转链 ─────────────
|
||||
@router.post("/referral-link", response_model=CpsReferralLinkOut, summary="生成群发短链(带 sid + 点击统计)")
|
||||
def generate_referral_link(
|
||||
body: CpsReferralLinkRequest,
|
||||
@router.patch("/activities/{activity_id}", response_model=CpsActivityOut, summary="编辑活动")
|
||||
def update_activity(
|
||||
activity_id: int,
|
||||
body: CpsActivityUpdate,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> CpsReferralLinkOut:
|
||||
) -> CpsActivityOut:
|
||||
activity = cps_repo.get_activity(db, activity_id)
|
||||
if activity is None:
|
||||
raise HTTPException(status_code=404, detail="活动不存在")
|
||||
# 用「合并后最终值」校验平台必填项(同新建口径),允许只改部分字段
|
||||
platform = body.platform or activity.platform
|
||||
act_id = body.act_id if body.act_id is not None else activity.act_id
|
||||
pvs = body.product_view_sign if body.product_view_sign is not None else activity.product_view_sign
|
||||
payload = body.payload if body.payload is not None else activity.payload
|
||||
image_url = body.image_url if body.image_url is not None else activity.image_url
|
||||
if platform == "meituan":
|
||||
if not act_id and not pvs:
|
||||
raise HTTPException(status_code=400, detail="美团活动需填 actId 或 productViewSign")
|
||||
else: # taobao / jd
|
||||
if not payload:
|
||||
label = "淘口令" if platform == "taobao" else "推广链接"
|
||||
raise HTTPException(status_code=400, detail=f"{platform} 活动需填{label}")
|
||||
if platform == "taobao" and not image_url:
|
||||
raise HTTPException(status_code=400, detail="淘宝活动需提供落地页图(上传或选已有)")
|
||||
cps_repo.update_activity(
|
||||
db, activity, name=body.name, platform=body.platform, act_id=body.act_id,
|
||||
product_view_sign=body.product_view_sign, payload=body.payload,
|
||||
image_url=media.to_abs_media_url(body.image_url) if body.image_url else None,
|
||||
remark=body.remark, status=body.status, commit=False,
|
||||
)
|
||||
write_audit(
|
||||
db, admin, action="cps.activity.update", target_type="cps_activity", target_id=activity_id,
|
||||
detail=body.model_dump(exclude_none=True), ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(activity)
|
||||
return CpsActivityOut.model_validate(activity)
|
||||
|
||||
|
||||
@router.post("/upload-image", summary="上传活动落地页图(返回绝对 URL)")
|
||||
async def upload_activity_image(
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
file: UploadFile = File(..., description="落地页图(jpeg/png/webp,≤5MB)"),
|
||||
) -> dict:
|
||||
data = await file.read()
|
||||
try:
|
||||
url = media.save_cps_image(admin.id, data)
|
||||
except media.MediaError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
return {"url": media.to_abs_media_url(url)}
|
||||
|
||||
|
||||
@router.get("/activity-images", summary="已有活动落地页图(供新建复用选择)")
|
||||
def activity_images(db: AdminDb) -> dict:
|
||||
return {"images": cps_repo.list_activity_images(db)}
|
||||
|
||||
|
||||
@router.delete("/activities/{activity_id}", summary="删除活动")
|
||||
def delete_activity(
|
||||
activity_id: int,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> dict:
|
||||
activity = cps_repo.get_activity(db, activity_id)
|
||||
if activity is None:
|
||||
raise HTTPException(status_code=404, detail="活动不存在")
|
||||
write_audit(
|
||||
db, admin, action="cps.activity.delete", target_type="cps_activity", target_id=activity_id,
|
||||
detail={"name": activity.name, "platform": activity.platform},
|
||||
ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
cps_repo.delete_activity(db, activity, commit=False)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ───────────── 生成链接(批量,按平台分支) ─────────────
|
||||
def _gen_one_link(db, group: CpsGroup, activity: CpsActivity):
|
||||
"""给一个活动生成一条落地页 link。美团:转链拿短链(需群 sid);淘宝/京东:用 payload。"""
|
||||
if activity.platform == "meituan":
|
||||
if not group.sid:
|
||||
raise HTTPException(status_code=400, detail=f"群「{group.name}」无 sid,无法生成美团链接")
|
||||
try:
|
||||
resp = meituan.get_referral_link(
|
||||
act_id=activity.act_id, product_view_sign=activity.product_view_sign,
|
||||
sid=group.sid, link_type_list=[1, 2, 3],
|
||||
)
|
||||
except MeituanCpsError as e:
|
||||
raise HTTPException(status_code=502, detail=f"美团转链失败: {e}") from e
|
||||
link_map = {str(k): v for k, v in (resp.get("referralLinkMap") or {}).items()}
|
||||
if not link_map and resp.get("data"):
|
||||
link_map = {"1": resp["data"]}
|
||||
target = link_map.get("2") or link_map.get("1") or link_map.get("3")
|
||||
if not target:
|
||||
raise HTTPException(status_code=502, detail=f"美团未返回有效链接(活动「{activity.name}」)")
|
||||
else: # taobao(淘口令) / jd(链接)
|
||||
if not activity.payload:
|
||||
raise HTTPException(status_code=400, detail=f"活动「{activity.name}」缺少内容")
|
||||
target = activity.payload
|
||||
return cps_link_repo.create_link(
|
||||
db, group_id=group.id, activity_id=activity.id, sid=group.sid,
|
||||
target_url=target, platform=activity.platform, commit=False,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/referral-links", response_model=CpsReferralLinksOut, summary="批量生成落地页短链")
|
||||
def generate_referral_links(
|
||||
body: CpsReferralLinksRequest,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> CpsReferralLinksOut:
|
||||
group = cps_repo.get_group(db, body.group_id)
|
||||
if group is None:
|
||||
raise HTTPException(status_code=404, detail="群不存在")
|
||||
activity = cps_repo.get_activity(db, body.activity_id)
|
||||
if activity is None:
|
||||
raise HTTPException(status_code=404, detail="活动不存在")
|
||||
if activity.platform != "meituan":
|
||||
raise HTTPException(status_code=400, detail=f"当前仅支持美团转链(活动平台={activity.platform})")
|
||||
try:
|
||||
# 全要:短链作跳转目标(微信可打开),长链/deeplink 作参考/备用
|
||||
resp = meituan.get_referral_link(
|
||||
act_id=activity.act_id,
|
||||
product_view_sign=activity.product_view_sign,
|
||||
sid=group.sid,
|
||||
link_type_list=[1, 2, 3],
|
||||
)
|
||||
except MeituanCpsError as e:
|
||||
raise HTTPException(status_code=502, detail=f"美团转链失败: {e}") from e
|
||||
group_platforms = set(group.platforms or [])
|
||||
base = settings.CPS_REDIRECT_BASE.rstrip("/")
|
||||
|
||||
link_map = {str(k): v for k, v in (resp.get("referralLinkMap") or {}).items()}
|
||||
if not link_map and resp.get("data"):
|
||||
link_map = {"1": resp["data"]}
|
||||
# 跳转目标优先短链(2,微信可打开)→ 长链(1)→ deeplink(3)
|
||||
target_url = link_map.get("2") or link_map.get("1") or link_map.get("3")
|
||||
if not target_url:
|
||||
raise HTTPException(status_code=502, detail="美团未返回有效链接(检查 actId 是否在推广有效期)")
|
||||
items: list[CpsReferralLinkItem] = []
|
||||
for aid in body.activity_ids:
|
||||
activity = cps_repo.get_activity(db, aid)
|
||||
if activity is None:
|
||||
raise HTTPException(status_code=404, detail=f"活动 {aid} 不存在")
|
||||
if activity.platform not in group_platforms:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"活动「{activity.name}」平台({activity.platform})不在群「{group.name}」范围内",
|
||||
)
|
||||
link = _gen_one_link(db, group, activity)
|
||||
items.append(CpsReferralLinkItem(
|
||||
activity_id=activity.id, activity_name=activity.name, platform=activity.platform,
|
||||
redirect_url=f"{base}/c/{link.code}" if base else f"/c/{link.code}", code=link.code,
|
||||
))
|
||||
|
||||
# 存我们的短链:发群用 /c/{code},用户点 → 我们记点击 → 302 跳 target_url
|
||||
link = cps_link_repo.create_link(
|
||||
db, group_id=group.id, activity_id=activity.id, sid=group.sid,
|
||||
target_url=target_url, platform=activity.platform, commit=False,
|
||||
)
|
||||
write_audit(
|
||||
db, admin, action="cps.referral_link.generate", target_type="cps_link", target_id=link.id,
|
||||
detail={"sid": group.sid, "activity_id": activity.id, "code": link.code},
|
||||
db, admin, action="cps.referral_link.generate", target_type="cps_group", target_id=group.id,
|
||||
detail={"group": group.name, "activity_ids": body.activity_ids, "count": len(items)},
|
||||
ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(link)
|
||||
|
||||
base = settings.CPS_REDIRECT_BASE.rstrip("/")
|
||||
redirect_url = f"{base}/c/{link.code}" if base else f"/c/{link.code}"
|
||||
return CpsReferralLinkOut(
|
||||
redirect_url=redirect_url,
|
||||
code=link.code,
|
||||
sid=group.sid,
|
||||
group_name=group.name,
|
||||
activity_name=activity.name,
|
||||
target_url=target_url,
|
||||
link_map=link_map,
|
||||
)
|
||||
return CpsReferralLinksOut(group_name=group.name, results=items)
|
||||
|
||||
|
||||
# ───────────── 订单对账 ─────────────
|
||||
@@ -262,7 +392,44 @@ def get_stats(
|
||||
stats = [CpsGroupStat(**r) for r in rows]
|
||||
return CpsStatsOut(
|
||||
groups=stats,
|
||||
total_order_count=sum(s.order_count for s in stats),
|
||||
total_est_commission_cents=sum(s.est_commission_cents for s in stats),
|
||||
total_settled_commission_cents=sum(s.settled_commission_cents for s in stats),
|
||||
total_order_count=sum(s.order_count or 0 for s in stats),
|
||||
total_est_commission_cents=sum(s.est_commission_cents or 0 for s in stats),
|
||||
total_settled_commission_cents=sum(s.settled_commission_cents or 0 for s in stats),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}/timeseries", summary="群点击时序(折线图:天/小时级 PV/UV/复制)")
|
||||
def group_timeseries(
|
||||
group_id: int,
|
||||
db: AdminDb,
|
||||
granularity: Annotated[str, Query(pattern="^(day|hour)$")] = "day",
|
||||
days: Annotated[int, Query(ge=1, le=30)] = 7,
|
||||
date: Annotated[str | None, Query()] = None, # hour 粒度看哪天(YYYY-MM-DD 北京),默认今天
|
||||
) -> dict:
|
||||
group = cps_repo.get_group(db, group_id)
|
||||
if group is None:
|
||||
raise HTTPException(status_code=404, detail="群不存在")
|
||||
bj = timezone(timedelta(hours=8))
|
||||
now_bj = datetime.now(bj)
|
||||
if granularity == "hour":
|
||||
if date:
|
||||
try:
|
||||
day0 = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=bj)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail="date 格式应为 YYYY-MM-DD") from e
|
||||
else:
|
||||
day0 = now_bj
|
||||
start = day0.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
end = start + timedelta(days=1) - timedelta(microseconds=1)
|
||||
else:
|
||||
start = (now_bj - timedelta(days=days - 1)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
end = now_bj
|
||||
points = cps_repo.group_click_timeseries(
|
||||
db, group_id=group_id, granularity=granularity, start=start, end=end
|
||||
)
|
||||
return {
|
||||
"group_id": group.id,
|
||||
"group_name": group.name,
|
||||
"granularity": granularity,
|
||||
"points": points,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""admin 比价记录 debug 页 schema(独立于 C 端 app/schemas/compare_record:admin 看任意用户、
|
||||
无 debug_trace 权限闸、展示全量 debug 字段)。phone/nickname 由 queries 瞬态挂在 ORM 实例上。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class AdminComparisonListItem(BaseModel):
|
||||
"""列表项:概要 + debug 维度概览(不含大字段 raw_payload/llm_calls/明细数组)。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
phone: str | None = None # join User 瞬态(非 DB 列)
|
||||
nickname: str | None = None # join User 瞬态
|
||||
business_type: str
|
||||
trace_id: str
|
||||
# admin 是 debug 工具,无条件下发 trace_url(不看 user.debug_trace_enabled)
|
||||
trace_url: str | None = None
|
||||
status: str
|
||||
information: str | None = None
|
||||
store_name: str | None = None
|
||||
source_platform_name: str | None = None
|
||||
best_platform_name: str | None = None
|
||||
source_price_cents: int | None = None
|
||||
best_price_cents: int | None = None
|
||||
saved_amount_cents: int | None = None
|
||||
# debug 概览
|
||||
total_ms: int | None = None
|
||||
step_count: int | None = None
|
||||
llm_call_count: int | None = None
|
||||
retry_count: int | None = None
|
||||
device_model: str | None = None
|
||||
rom_vendor: str | None = None
|
||||
rom_name: str | None = None
|
||||
android_version: str | None = None
|
||||
app_version: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AdminComparisonDetail(AdminComparisonListItem):
|
||||
"""详情:概要 + 全量明细(逐平台对比 / LLM 每次调用 / 原始 payload)。"""
|
||||
|
||||
source_platform_id: str | None = None
|
||||
source_package: str | None = None
|
||||
best_platform_id: str | None = None
|
||||
best_deeplink: str | None = None
|
||||
is_source_best: bool | None = None
|
||||
total_dish_count: int | None = None
|
||||
skipped_dish_count: int | None = None
|
||||
device_id: str | None = None
|
||||
items: list = []
|
||||
comparison_results: list = [] # 逐平台对比(价格/rank/coupon/打烊...)
|
||||
skipped_dish_names: list = []
|
||||
# 全量环境
|
||||
device_manufacturer: str | None = None
|
||||
rom_version: int | None = None
|
||||
android_sdk: int | None = None
|
||||
app_version_code: int | None = None
|
||||
source_app_version: str | None = None
|
||||
longitude: float | None = None
|
||||
latitude: float | None = None
|
||||
# 每次 LLM 调用明细 [{scene,model,input_messages,output,usage,latency_ms,error}]
|
||||
llm_calls: list | None = None
|
||||
# 原始上报全量;「卡在哪一步」从 raw_payload.platform_results[*].status 读
|
||||
# (store_not_found/items_not_found/below_minimum/unsupported = 卡在 找店/加菜/起送/读价)。
|
||||
raw_payload: dict | None = None
|
||||
+65
-32
@@ -1,4 +1,8 @@
|
||||
"""admin CPS 分发与对账 schemas。金额统一「分」(cents),前端 yuan() 展示。"""
|
||||
"""admin CPS 分发与对账 schemas。金额统一「分」(cents),前端 yuan() 展示。
|
||||
|
||||
平台:meituan(actId+sid 转链对账) / taobao(淘口令,只统计点击) / jd(链接,只统计点击)。
|
||||
对账类字段对淘宝/京东为 None → 前端显示 "-"(无法对账)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
@@ -6,13 +10,14 @@ from datetime import datetime
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
# ───────────── 群(sid) ─────────────
|
||||
# ───────────── 群 ─────────────
|
||||
class CpsGroupOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
sid: str
|
||||
sid: str | None = None # 仅美团群有
|
||||
name: str
|
||||
platforms: list[str] = Field(default_factory=list)
|
||||
member_count: int | None = None
|
||||
status: str
|
||||
remark: str | None = None
|
||||
@@ -21,7 +26,9 @@ class CpsGroupOut(BaseModel):
|
||||
|
||||
class CpsGroupCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=128)
|
||||
# 留空 → 后端自动生成 g<id>;填写则必须字母+数字(美团 sid 限制)。
|
||||
# 该群发哪些平台,至少选一个
|
||||
platforms: list[str] = Field(min_length=1)
|
||||
# 仅当含 meituan 时需要(留空自动生成);纯淘宝/京东不填,后端置 None。
|
||||
sid: str | None = Field(default=None, max_length=64, pattern=r"^[A-Za-z0-9]+$")
|
||||
member_count: int | None = Field(default=None, ge=0)
|
||||
remark: str | None = Field(default=None, max_length=256)
|
||||
@@ -29,12 +36,13 @@ class CpsGroupCreate(BaseModel):
|
||||
|
||||
class CpsGroupUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=128)
|
||||
platforms: list[str] | None = None
|
||||
member_count: int | None = Field(default=None, ge=0)
|
||||
status: str | None = Field(default=None, pattern=r"^(active|archived)$")
|
||||
remark: str | None = Field(default=None, max_length=256)
|
||||
|
||||
|
||||
# ───────────── 活动(actId) ─────────────
|
||||
# ───────────── 活动 ─────────────
|
||||
class CpsActivityOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -43,6 +51,8 @@ class CpsActivityOut(BaseModel):
|
||||
name: str
|
||||
act_id: str | None = None
|
||||
product_view_sign: str | None = None
|
||||
payload: str | None = None # 淘宝淘口令 / 京东链接
|
||||
image_url: str | None = None # 淘宝落地页图(绝对 URL)
|
||||
status: str
|
||||
remark: str | None = None
|
||||
created_at: datetime
|
||||
@@ -50,36 +60,55 @@ class CpsActivityOut(BaseModel):
|
||||
|
||||
class CpsActivityCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=128)
|
||||
platform: str = Field(default="meituan", pattern=r"^(meituan|taobao|jd)$")
|
||||
platform: str = Field(pattern=r"^(meituan|taobao|jd)$")
|
||||
# 美团:act_id 或 product_view_sign 二选一
|
||||
act_id: str | None = Field(default=None, max_length=64)
|
||||
product_view_sign: str | None = Field(default=None, max_length=128)
|
||||
# 淘宝:整段淘口令文本 / 京东:推广链接
|
||||
payload: str | None = Field(default=None, max_length=4096)
|
||||
# 淘宝落地页图(绝对/相对 URL;淘宝必填,由 router 校验)
|
||||
image_url: str | None = Field(default=None, max_length=512)
|
||||
remark: str | None = Field(default=None, max_length=256)
|
||||
|
||||
|
||||
# ───────────── 转链 ─────────────
|
||||
class CpsReferralLinkRequest(BaseModel):
|
||||
class CpsActivityUpdate(BaseModel):
|
||||
"""编辑活动(PATCH):全字段可选,只更新传入的非 None 项;最终平台对应字段由 router 校验。"""
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=128)
|
||||
platform: str | None = Field(default=None, pattern=r"^(meituan|taobao|jd)$")
|
||||
act_id: str | None = Field(default=None, max_length=64)
|
||||
product_view_sign: str | None = Field(default=None, max_length=128)
|
||||
payload: str | None = Field(default=None, max_length=4096)
|
||||
image_url: str | None = Field(default=None, max_length=512)
|
||||
remark: str | None = Field(default=None, max_length=256)
|
||||
status: str | None = Field(default=None, pattern=r"^(active|archived)$")
|
||||
|
||||
|
||||
# ───────────── 生成链接(批量) ─────────────
|
||||
class CpsReferralLinksRequest(BaseModel):
|
||||
group_id: int
|
||||
activity_ids: list[int] = Field(min_length=1)
|
||||
|
||||
|
||||
class CpsReferralLinkItem(BaseModel):
|
||||
activity_id: int
|
||||
# 链接类型:1长链 2短链 3deeplink。默认短链(微信可打开)+deeplink。
|
||||
link_types: list[int] = Field(default_factory=lambda: [2, 3])
|
||||
|
||||
|
||||
class CpsReferralLinkOut(BaseModel):
|
||||
redirect_url: str # ★ 我们的群发短链(/c/{code}),发群就用这个,点击经我们统计
|
||||
code: str
|
||||
sid: str
|
||||
group_name: str
|
||||
activity_name: str
|
||||
target_url: str # 实际 302 跳转的美团短链
|
||||
link_map: dict[str, str] # 美团原始 1长链/2短链/3deeplink(参考/备用)
|
||||
platform: str
|
||||
redirect_url: str # 我们的落地页 /c/{code}(发群用)
|
||||
code: str
|
||||
|
||||
|
||||
class CpsReferralLinksOut(BaseModel):
|
||||
group_name: str
|
||||
results: list[CpsReferralLinkItem]
|
||||
|
||||
|
||||
# ───────────── 对账拉单 ─────────────
|
||||
class CpsReconcileResult(BaseModel):
|
||||
fetched: int # 本次美团返回的订单条数
|
||||
inserted: int # 新入库
|
||||
updated: int # 已存在→更新状态/佣金
|
||||
pages: int # 翻了几页
|
||||
fetched: int
|
||||
inserted: int
|
||||
updated: int
|
||||
pages: int
|
||||
|
||||
|
||||
# ───────────── 订单明细 ─────────────
|
||||
@@ -101,18 +130,22 @@ class CpsOrderOut(BaseModel):
|
||||
|
||||
# ───────────── 统计 ─────────────
|
||||
class CpsGroupStat(BaseModel):
|
||||
group_id: int | None = None # None = 未归群的 sid(历史/其它来源)
|
||||
group_id: int | None = None
|
||||
sid: str | None = None
|
||||
name: str
|
||||
platforms: list[str] = Field(default_factory=list)
|
||||
member_count: int | None = None
|
||||
click_pv: int = 0 # 点击总次数
|
||||
click_uv: int = 0 # 独立点击(按 ip+ua 近似去重)
|
||||
order_count: int # 有效订单数(不含取消/风控)
|
||||
settled_count: int # 已结算单数
|
||||
canceled_count: int # 取消+风控单数
|
||||
gmv_cents: int # 成交额(有效单 payPrice 合计)
|
||||
est_commission_cents: int # 预估佣金(有效单 profit 合计)
|
||||
settled_commission_cents: int # 已结算佣金
|
||||
click_pv: int = 0
|
||||
click_uv: int = 0
|
||||
copy_pv: int = 0 # 淘宝"复制口令"次数
|
||||
copy_uv: int = 0
|
||||
# 对账类:淘宝/京东无法对账 → None(前端显示 "-");美团群为数值
|
||||
order_count: int | None = None
|
||||
settled_count: int | None = None
|
||||
canceled_count: int | None = None
|
||||
gmv_cents: int | None = None
|
||||
est_commission_cents: int | None = None
|
||||
settled_commission_cents: int | None = None
|
||||
|
||||
|
||||
class CpsStatsOut(BaseModel):
|
||||
|
||||
+42
-4
@@ -29,6 +29,8 @@ from app.schemas.ad import (
|
||||
AdRewardStatusOut,
|
||||
EcpmReportIn,
|
||||
EcpmReportOut,
|
||||
FeedPreviewIn,
|
||||
FeedPreviewOut,
|
||||
FeedRewardIn,
|
||||
FeedRewardOut,
|
||||
PangleCallbackOut,
|
||||
@@ -343,6 +345,23 @@ def test_grant(user: CurrentUser, db: DbSession, payload: TestGrantIn | None = N
|
||||
)
|
||||
|
||||
|
||||
def _feed_segments(payload: FeedRewardIn) -> tuple[list[tuple[str, int]], str]:
|
||||
"""把 feed-reward 入参归一成 `(segments, ecpm_repr)`。
|
||||
|
||||
优先用逐条 segments(每条自带 eCPM);未传则回退把 ecpm + duration_seconds 当单段(旧客户端)。
|
||||
ecpm_repr 取末段 eCPM(报表/审计落库代表值)。两者都缺 → 422。
|
||||
"""
|
||||
if payload.segments:
|
||||
segs = [(s.ecpm, max(0, s.seconds)) for s in payload.segments]
|
||||
return segs, segs[-1][0]
|
||||
if payload.ecpm is not None:
|
||||
return [(payload.ecpm, max(0, payload.duration_seconds))], payload.ecpm
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="feed-reward 需提供 segments 或 ecpm",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/feed-reward",
|
||||
response_model=FeedRewardOut,
|
||||
@@ -350,16 +369,18 @@ def test_grant(user: CurrentUser, db: DbSession, payload: TestGrantIn | None = N
|
||||
dependencies=[Depends(rate_limit(120, 60, "ad-feed-reward"))],
|
||||
)
|
||||
def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> FeedRewardOut:
|
||||
"""点位 2:信息流广告每展示满 10 秒累计一份奖励,视频完成后一次性入账。
|
||||
"""点位 2:信息流广告每展示满 10 秒累计一份奖励,整场结束(全程不关)一次性入账。
|
||||
|
||||
当前一期由客户端完成回调携带 eCPM / 展示秒数上报;client_event_id 做幂等键,避免重试重复发。
|
||||
新客户端按条上报 [segments](每条 eCPM + 秒数),服务端按时序跨条进位精确结算;旧客户端只传
|
||||
ecpm + duration_seconds 时回退当单段。client_event_id 做幂等键,避免重试重复发。
|
||||
"""
|
||||
segments, ecpm_repr = _feed_segments(payload)
|
||||
rec = crud_feed.grant_feed_reward(
|
||||
db,
|
||||
user.id,
|
||||
client_event_id=payload.client_event_id,
|
||||
ecpm=payload.ecpm,
|
||||
duration_seconds=payload.duration_seconds,
|
||||
segments=segments,
|
||||
ecpm_repr=ecpm_repr,
|
||||
ad_session_id=payload.ad_session_id,
|
||||
adn=payload.adn,
|
||||
slot_id=payload.slot_id,
|
||||
@@ -380,6 +401,23 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/feed-reward/preview",
|
||||
response_model=FeedPreviewOut,
|
||||
summary="信息流播放中预演此刻将发放的金币(只读,不入账)",
|
||||
dependencies=[Depends(rate_limit(600, 60, "ad-feed-preview"))],
|
||||
)
|
||||
def feed_reward_preview(payload: FeedPreviewIn, user: CurrentUser, db: DbSession) -> FeedPreviewOut:
|
||||
"""比价/领券金币小球实时显示**真实即将到账金额**用:按截至此刻的逐条 segments 算,与
|
||||
/feed-reward 同一口径(同份数封顶 / 同 LT / 同 eCPM 钳),但**不入账、不写库**。
|
||||
|
||||
限流放宽到 600/分:小球每满 10 秒(或换条)拉一次,一整场最多十几次,留足并发余量。
|
||||
"""
|
||||
segments = [(s.ecpm, max(0, s.seconds)) for s in payload.segments]
|
||||
result = crud_feed.preview_feed_reward(db, user.id, segments)
|
||||
return FeedPreviewOut(**result)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/reward-noshow",
|
||||
response_model=RewardNoShowOut,
|
||||
|
||||
@@ -116,3 +116,11 @@ async def intent_precoupon_step(request: Request) -> dict[str, Any]:
|
||||
@router.post("/price/step", summary="外卖比价 Phase 2 步进 (透传到 pricebot)")
|
||||
async def price_step(request: Request) -> dict[str, Any]:
|
||||
return await _passthrough(request, "/api/price/step")
|
||||
|
||||
|
||||
@router.post("/trace/finalize", summary="比价 trace 收尾上云 (透传到 pricebot, 终止/未识别拿 trace_url)")
|
||||
async def trace_finalize(request: Request) -> dict[str, Any]:
|
||||
# 用户终止 / Phase1 未识别没走到 done 帧, pricebot 没上云也没回传 trace_url。客户端收尾时
|
||||
# 打这个, _passthrough 按 trace_id 一致性 hash 落到处理这条 trace 的同一 pricebot 进程
|
||||
# (dir_cache 在那, 才能算对 trace 目录), 由后者打包上云返回 {trace_url}。
|
||||
return await _passthrough(request, "/api/trace/finalize")
|
||||
|
||||
@@ -19,6 +19,7 @@ from fastapi import APIRouter, HTTPException, Query, status
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.repositories import comparison as crud_compare
|
||||
from app.services.pricebot_llm_calls import fetch_llm_calls
|
||||
from app.schemas.compare_record import (
|
||||
CompareStatsOut,
|
||||
ComparisonRecordCreatedOut,
|
||||
@@ -42,13 +43,22 @@ def report_record(
|
||||
payload: ComparisonRecordIn, user: CurrentUser, db: DbSession
|
||||
) -> ComparisonRecordCreatedOut:
|
||||
rec = crud_compare.upsert_record(db, user_id=user.id, payload=payload)
|
||||
# 同机拉 pricebot 本次比价的 LLM 调用明细落库(best-effort,失败不阻断上报)。
|
||||
# llm_call_count/retry_count 直接由明细派生:次数=条数,重试=带 error 的条数。
|
||||
calls = fetch_llm_calls(rec.trace_id)
|
||||
if calls:
|
||||
rec.llm_calls = calls
|
||||
rec.llm_call_count = len(calls)
|
||||
rec.retry_count = sum(1 for c in calls if c.get("error"))
|
||||
db.commit()
|
||||
logger.info(
|
||||
"compare record user=%s trace=%s biz=%s status=%s saved=%s",
|
||||
"compare record user=%s trace=%s biz=%s status=%s saved=%s llm_calls=%d",
|
||||
user.id,
|
||||
rec.trace_id,
|
||||
rec.business_type,
|
||||
rec.status,
|
||||
rec.saved_amount_cents,
|
||||
len(calls),
|
||||
)
|
||||
return ComparisonRecordCreatedOut(id=rec.id)
|
||||
|
||||
@@ -73,13 +83,20 @@ def list_records(
|
||||
db: DbSession,
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
cursor: int | None = Query(None, description="上一页末条 id"),
|
||||
include_trace: bool = Query(
|
||||
False,
|
||||
description="客户端开了本机 agent 调试模式时带 true,放行本人记录的 trace_url",
|
||||
),
|
||||
) -> ComparisonRecordPage:
|
||||
items, next_cursor = crud_compare.list_records(
|
||||
db, user.id, limit=limit, cursor=cursor
|
||||
)
|
||||
outs = [ComparisonRecordOut.model_validate(it) for it in items]
|
||||
# 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url(列表页「复制调试链接」靠它)
|
||||
if not user.debug_trace_enabled:
|
||||
# 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url(列表页「复制调试链接」靠它)。
|
||||
# include_trace=true 例外:客户端开了本机 agent 调试模式时带上,放行**本人记录**的 trace_url
|
||||
# ——list_records 只查 user.id 自己的记录,给本人看自己的调试链接无越权,与实时结果页
|
||||
# CompResultScreen「debug 权限 OR 本机 agent 调试」同口径(领导 2026-06-12 拍板)。
|
||||
if not (user.debug_trace_enabled or include_trace):
|
||||
for o in outs:
|
||||
o.trace_url = None
|
||||
return ComparisonRecordPage(items=outs, next_cursor=next_cursor)
|
||||
|
||||
+94
-11
@@ -1,21 +1,32 @@
|
||||
"""CPS 群发短链跳转:用户点 /c/{code} → 记一条点击 → 302 跳美团。
|
||||
"""CPS 群发短链落地:用户点 /c/{code} → 记点击 → 按平台 302 跳 或 返回淘宝落地页。
|
||||
|
||||
公网、无鉴权(群里任何人点都要能跳)。记点击失败绝不影响跳转(用户体验优先)。
|
||||
公网无鉴权(群里任何人点都要能跳/能领)。记点击失败绝不影响用户。
|
||||
- 美团/京东:记 visit + 302 跳 target(美团短链 / 京东链接)
|
||||
- 淘宝:记 visit + 返回 H5 落地页(整段淘口令复制按钮);点"复制口令"→ POST /c/{code}/copy 记 copy
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import media
|
||||
from app.db.session import get_db
|
||||
from app.models.cps_activity import CpsActivity
|
||||
from app.models.cps_link import CpsLink
|
||||
from app.repositories import cps_link as cps_link_repo
|
||||
|
||||
router = APIRouter(tags=["cps-redirect"])
|
||||
|
||||
# code 不存在/失效时的兜底落地(避免用户看到报错)
|
||||
# code 不存在/失效时的兜底落地
|
||||
_FALLBACK_URL = "https://www.meituan.com/"
|
||||
|
||||
# 淘宝活动未设图时的兜底主视觉(存量已回填,基本只在老 link/异常时触发)
|
||||
_DEFAULT_TAOBAO_IMAGE = "/media/taobao_landing.jpg"
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str | None:
|
||||
xff = request.headers.get("x-forwarded-for")
|
||||
@@ -24,16 +35,88 @@ def _client_ip(request: Request) -> str | None:
|
||||
return request.client.host if request.client else None
|
||||
|
||||
|
||||
@router.get("/c/{code}", summary="群发短链跳转(记点击 + 302)")
|
||||
def cps_redirect(code: str, request: Request, db: Session = Depends(get_db)):
|
||||
link = cps_link_repo.get_by_code(db, code)
|
||||
if link is None:
|
||||
return RedirectResponse(_FALLBACK_URL, status_code=302)
|
||||
def _record(db: Session, link: CpsLink, request: Request, event_type: str) -> None:
|
||||
try:
|
||||
cps_link_repo.record_click(
|
||||
db, link=link, ip=_client_ip(request),
|
||||
ua=request.headers.get("user-agent"),
|
||||
ua=request.headers.get("user-agent"), event_type=event_type,
|
||||
)
|
||||
except Exception:
|
||||
pass # 记点击失败不阻断跳转
|
||||
pass # 记点击失败不阻断
|
||||
|
||||
|
||||
@router.get("/c/{code}", summary="群发短链落地(记点击 + 跳转/淘宝落地页)")
|
||||
def cps_landing(code: str, request: Request, db: Session = Depends(get_db)):
|
||||
link = cps_link_repo.get_by_code(db, code)
|
||||
if link is None:
|
||||
return RedirectResponse(_FALLBACK_URL, status_code=302)
|
||||
_record(db, link, request, "visit")
|
||||
if link.platform == "taobao":
|
||||
activity = db.get(CpsActivity, link.activity_id)
|
||||
image_url = (activity.image_url if activity else None) or _DEFAULT_TAOBAO_IMAGE
|
||||
return HTMLResponse(_taobao_landing_html(link.target_url, image_url))
|
||||
# 美团短链 / 京东链接:直接 302 跳
|
||||
return RedirectResponse(link.target_url, status_code=302)
|
||||
|
||||
|
||||
@router.post("/c/{code}/copy", summary="淘宝落地页点了「复制口令」(记 copy 事件)")
|
||||
def cps_copy(code: str, request: Request, db: Session = Depends(get_db)) -> dict:
|
||||
link = cps_link_repo.get_by_code(db, code)
|
||||
if link is not None:
|
||||
_record(db, link, request, "copy")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _taobao_landing_html(token: str, image_url: str) -> str:
|
||||
"""淘宝落地页 H5(主视觉图按活动传入,复制淘口令按钮在 75% 屏高)。
|
||||
|
||||
image_url 转绝对(落地页 coupon 域,相对也可达,绝对更稳)后经 html.escape 嵌入
|
||||
<img src>(防属性注入);token 经 json.dumps 安全嵌入 JS。
|
||||
"""
|
||||
safe_img = html.escape(media.to_abs_media_url(image_url) or image_url, quote=True)
|
||||
return (
|
||||
_TAOBAO_HTML
|
||||
.replace("__IMAGE_URL__", safe_img)
|
||||
.replace("__TOKEN_JS__", json.dumps(token))
|
||||
)
|
||||
|
||||
|
||||
_TAOBAO_HTML = """<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
|
||||
<title>淘宝闪购 · 天天领红包</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
|
||||
body{font-family:-apple-system,"PingFang SC",sans-serif;background:#fff0ef;color:#333;min-height:100vh}
|
||||
.hero{display:block;width:100%}
|
||||
.btn-wrap{position:fixed;left:0;right:0;top:75vh;padding:0 20px}
|
||||
.btn{display:block;width:100%;background:linear-gradient(90deg,#ff5b5b,#ff3b3b);color:#fff;font-size:19px;font-weight:800;text-align:center;padding:16px;border:none;border-radius:30px;box-shadow:0 6px 16px rgba(255,59,59,.4)}
|
||||
.btn:active{transform:scale(.98)}
|
||||
.toast{position:fixed;left:50%;top:42%;transform:translate(-50%,-50%);background:rgba(0,0,0,.82);color:#fff;padding:12px 22px;border-radius:10px;font-size:15px;opacity:0;transition:opacity .25s;pointer-events:none;z-index:99;white-space:nowrap}
|
||||
.toast.show{opacity:1}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img class="hero" src="__IMAGE_URL__" alt="淘宝闪购 天天领红包">
|
||||
<div class="btn-wrap"><button class="btn" onclick="copyToken()">复制口令去淘宝领红包</button></div>
|
||||
<div class="toast" id="toast"></div>
|
||||
<script>
|
||||
var TOKEN = __TOKEN_JS__;
|
||||
function showToast(m){var t=document.getElementById('toast');t.textContent=m;t.classList.add('show');setTimeout(function(){t.classList.remove('show')},2200)}
|
||||
function reportCopy(){try{fetch(location.pathname+'/copy',{method:'POST',keepalive:true})}catch(e){}}
|
||||
function done(){showToast('复制成功!打开淘宝即可领取');reportCopy()}
|
||||
function fallback(){
|
||||
var ta=document.createElement('textarea');ta.value=TOKEN;ta.style.position='fixed';ta.style.opacity='0';
|
||||
document.body.appendChild(ta);ta.focus();ta.select();
|
||||
try{document.execCommand('copy');done()}catch(e){showToast('复制失败,请长按手动复制')}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
function copyToken(){
|
||||
if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(TOKEN).then(done).catch(fallback)}
|
||||
else{fallback()}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
@@ -67,6 +67,23 @@ def save_report_image(user_id: int, data: bytes) -> str:
|
||||
return _save_image("price_report", user_id, data)
|
||||
|
||||
|
||||
def save_cps_image(admin_id: int, data: bytes) -> str:
|
||||
"""保存 CPS 活动落地页图,返回相对 URL(`/media/cps/<file>`)。admin_id 入文件名便于追溯。"""
|
||||
return _save_image("cps", admin_id, data)
|
||||
|
||||
|
||||
def to_abs_media_url(rel_url: str | None) -> str | None:
|
||||
"""相对 `/media/...` → 基于 CPS_REDIRECT_BASE 的绝对 URL(CPS 落地页域名,跨域可访问)。
|
||||
|
||||
已是绝对 URL / 空 / base 未配(dev) 时原样返回。CPS 活动图入库前转绝对,使落地页
|
||||
(coupon 域)与 admin-web(另一域,不 serve /media)都能直接 <img src> 加载。
|
||||
"""
|
||||
if not rel_url or rel_url.startswith(("http://", "https://")):
|
||||
return rel_url
|
||||
base = settings.CPS_REDIRECT_BASE.rstrip("/")
|
||||
return f"{base}{rel_url}" if base else rel_url
|
||||
|
||||
|
||||
def delete_avatar(url: str | None) -> None:
|
||||
"""删除本服务托管的旧头像文件;外部 URL(如微信头像)或空值不处理。"""
|
||||
prefix = f"{settings.MEDIA_URL_PREFIX}/avatars/"
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
由客户端生成,并通过激励视频 extra 透传给 S2S 回调,用于把"展示 eCPM"与
|
||||
"奖励完成"绑定。没有 session id 的旧上报仍可作为按天对账补充。
|
||||
|
||||
⚠️ `ecpm_raw` 原样存客户端上报的字符串——eCPM 单位(分 / 元)截至 2026-05-31 尚未最终确认,
|
||||
确认后再加一列解析好的数值;在此之前对账按"待定单位"处理。
|
||||
`ecpm_raw` 原样存客户端上报的字符串——单位是穿山甲 getEcpm 官方口径【分/千次展示】
|
||||
(非元!),对账由 `core/rewards.parse_ecpm_fen` 解析、`parse_ecpm_yuan` ÷100 转元;本列只存原值。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
@@ -101,6 +102,32 @@ class ComparisonRecord(Base):
|
||||
# 客户端上报的原始 payload(calibration + done.params 全量),未来取数兜底
|
||||
raw_payload: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
|
||||
|
||||
# ===== debug 维度(客户端上报;旧记录 / 未发版客户端为 None)=====
|
||||
# 设备环境:无障碍比价高度依赖机型/ROM,这是排"某机型跑不通"类问题的头号线索
|
||||
device_model: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
device_manufacturer: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
rom_vendor: Mapped[str | None] = mapped_column(String(32), nullable=True) # vivo/OPPO/Xiaomi/HUAWEI/HONOR/samsung
|
||||
rom_name: Mapped[str | None] = mapped_column(String(32), nullable=True) # OriginOS/ColorOS/HyperOS/MIUI/EMUI/HarmonyOS...
|
||||
rom_version: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
android_version: Mapped[str | None] = mapped_column(String(16), nullable=True) # Build.VERSION.RELEASE
|
||||
android_sdk: Mapped[int | None] = mapped_column(Integer, nullable=True) # Build.VERSION.SDK_INT
|
||||
app_version: Mapped[str | None] = mapped_column(String(32), nullable=True) # 我们 app versionName
|
||||
app_version_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# 被操控的源平台 App 版本(美团/淘宝/京东),排"平台 App 改版导致 UI 适配失效"用
|
||||
source_app_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
# 比价时设备定位(影响淘宝 deeplink / 距离匹配)
|
||||
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
# ===== 性能 / 过程统计(debug 用)=====
|
||||
total_ms: Mapped[int | None] = mapped_column(Integer, nullable=True) # 客户端整场比价墙钟耗时
|
||||
step_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # 客户端 agent loop 总步数
|
||||
llm_call_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # 本次 LLM 调用次数(server 从 llm_calls 算)
|
||||
retry_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # LLM 失败重试次数(server 从 llm_calls error 算)
|
||||
# 每次 LLM 调用明细 [{scene,model,input_messages,output,usage,latency_ms,error}];
|
||||
# server 收上报后按 trace_id 同机拉 pricebot 落库(见 compare_record 端点)。旧记录/未采集为 None。
|
||||
llm_calls: Mapped[list | None] = mapped_column(_JSON, nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, func
|
||||
from sqlalchemy import DateTime, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
@@ -24,6 +24,10 @@ class CpsActivity(Base):
|
||||
act_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# 美团商品券 productViewSign(与 act_id 二选一转链;多数活动用 act_id)。
|
||||
product_view_sign: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# 淘宝:整段淘口令文本(落地页原样复制);京东:推广链接(落地页 302 跳)。美团不用这个(用 act_id)。
|
||||
payload: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# 淘宝落地页主视觉图(绝对 URL,基于 CPS_REDIRECT_BASE)。运营建活动时上传/选已有,落地页按活动展示。
|
||||
image_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active") # active | archived
|
||||
remark: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
|
||||
@@ -7,7 +7,8 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, func
|
||||
from sqlalchemy import JSON, DateTime, Integer, String, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
@@ -17,9 +18,13 @@ class CpsGroup(Base):
|
||||
__tablename__ = "cps_group"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
# 每群唯一的渠道标识,建群时分配(留空则自动生成 g<id>)。字母+数字,≤64。
|
||||
sid: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||
# 渠道标识。仅美团需要(sid 追踪);纯淘宝/京东群无 sid(那俩不支持)。含美团时建群分配。
|
||||
sid: Mapped[str | None] = mapped_column(String(64), unique=True, index=True, nullable=True)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
# 该群发哪些平台的券(多选): ["meituan","taobao","jd"]。含 meituan 才需要 sid。
|
||||
platforms: Mapped[list] = mapped_column(
|
||||
JSON().with_variant(JSONB(), "postgresql"), nullable=False, default=list
|
||||
)
|
||||
# 群人数,运营填,作转化率分母(可空)。
|
||||
member_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active") # active | archived
|
||||
|
||||
@@ -23,10 +23,11 @@ class CpsLink(Base):
|
||||
code: Mapped[str] = mapped_column(String(16), unique=True, index=True, nullable=False)
|
||||
group_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
|
||||
activity_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
|
||||
sid: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||
# 仅美团 link 有 sid(渠道追踪);淘宝/京东无 sid。
|
||||
sid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
platform: Mapped[str] = mapped_column(String(20), nullable=False, default="meituan")
|
||||
# 302 跳转目标(美团短链,微信内可打开并唤起 App)。
|
||||
target_url: Mapped[str] = mapped_column(String(1024), nullable=False)
|
||||
# 跳转/落地目标: 美团短链 / 京东链接(302 跳) / 淘宝整段淘口令文本(H5 落地页复制)。
|
||||
target_url: Mapped[str] = mapped_column(String(2048), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
@@ -41,7 +42,11 @@ class CpsClick(Base):
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
link_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
|
||||
group_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) # 冗余,按群聚合快
|
||||
sid: Mapped[str] = mapped_column(String(64), index=True, nullable=False) # 冗余
|
||||
sid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) # 冗余(淘宝/京东无)
|
||||
# 事件类型: visit(进落地页/被跳转) | copy(淘宝落地页点了"复制口令")
|
||||
event_type: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="visit", server_default="visit"
|
||||
)
|
||||
ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
ua: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
clicked_at: Mapped[datetime] = mapped_column(
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""签到记录表。
|
||||
|
||||
每次签到一行,(user_id, signin_date) 唯一,天然防一天签两次。
|
||||
- cycle_day: 1..14,14 天循环里今天落在第几档,决定发多少金币;断签后重置回 1。
|
||||
- cycle_day: 1..7,7 天循环里今天落在第几档,决定发多少金币;断签后重置回 1
|
||||
(周期长度 = rewards.SIGNIN_CYCLE_LEN,2026-06 由 14 天改 7 天一轮)。
|
||||
- streak: 连续签到天数(不封顶),用于"已连续签到 N 天"展示;断签后重置回 1。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -16,10 +16,13 @@ from app.repositories import wallet as crud_wallet
|
||||
|
||||
|
||||
FEED_REWARD_UNIT_SECONDS = 10
|
||||
# 单个 feed 事件的时长上限(秒):一期 duration_seconds 由客户端上报,伪造超长时长会刷份数
|
||||
# 单个 feed 事件的时长上限(秒):一期时长由客户端上报,伪造超长时长会刷份数
|
||||
# (每 10 秒 1 份)。真实单条信息流视频远小于此;取 120s=12 份封顶,挡刷量、不影响正规单。
|
||||
# 与 rewards.AD_ECPM_MAX_FEN(eCPM 钳顶)合起来,把单事件可铸金币锁进有限区间。
|
||||
FEED_MAX_DURATION_SECONDS = 120
|
||||
# 单场份数上限(=120s//10):多条广告按时序「跨条进位」累计满 10 秒一份,封顶 12 份/场。
|
||||
# 时长改由逐条 segments 上报后,防刷的硬闸从「单事件时长钳」上移到这个份数上限(配合 eCPM 钳顶)。
|
||||
FEED_MAX_UNITS = FEED_MAX_DURATION_SECONDS // FEED_REWARD_UNIT_SECONDS
|
||||
|
||||
|
||||
def _find_by_event(db: Session, client_event_id: str) -> AdFeedRewardRecord | None:
|
||||
@@ -42,21 +45,89 @@ def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
|
||||
).scalar_one()
|
||||
|
||||
|
||||
def _unit_reward_total(db: Session, user_id: int, ecpm: str, unit_count: int) -> int:
|
||||
"""按每个 10 秒单位逐份计算奖励,LT 使用**账号累计**奖励份序号(不按天重置)。"""
|
||||
if unit_count <= 0:
|
||||
return 0
|
||||
existing_units = db.execute(
|
||||
select(func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0))
|
||||
.where(
|
||||
AdFeedRewardRecord.user_id == user_id,
|
||||
AdFeedRewardRecord.status == "granted",
|
||||
def _existing_granted_units(db: Session, user_id: int) -> int:
|
||||
"""该账号历史已 granted 的累计份数(LT 因子起点,不按天重置)。"""
|
||||
return int(
|
||||
db.execute(
|
||||
select(func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0)).where(
|
||||
AdFeedRewardRecord.user_id == user_id,
|
||||
AdFeedRewardRecord.status == "granted",
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def _walk_unit_ecpms(segments: list[tuple[str, int]]) -> list[str]:
|
||||
"""按时序遍历各广告段,**跨条进位**累计满 10 秒结算一份,返回每份对应的 eCPM。
|
||||
|
||||
每份归属「它满 10 秒那一刻正在播的那条广告的 eCPM」:换条时不足一份的零头进位到下一条
|
||||
(与旧版 `总时长//10` 份数口径一致、不浪费零头,但每份 eCPM 归属精确)。封顶 [FEED_MAX_UNITS]
|
||||
份;尾部不足一份的秒数丢弃。segments 为 `[(ecpm, seconds), ...]`,seconds≤0 的段跳过。
|
||||
"""
|
||||
unit_ecpms: list[str] = []
|
||||
carry = 0
|
||||
for ecpm, seconds in segments:
|
||||
if seconds <= 0:
|
||||
continue
|
||||
carry += seconds
|
||||
while carry >= FEED_REWARD_UNIT_SECONDS and len(unit_ecpms) < FEED_MAX_UNITS:
|
||||
carry -= FEED_REWARD_UNIT_SECONDS
|
||||
unit_ecpms.append(ecpm)
|
||||
if len(unit_ecpms) >= FEED_MAX_UNITS:
|
||||
break
|
||||
return unit_ecpms
|
||||
|
||||
|
||||
def compute_feed_reward(
|
||||
db: Session, user_id: int, segments: list[tuple[str, int]]
|
||||
) -> tuple[int, int, int]:
|
||||
"""按逐条 segments 计算「此刻若结束将发放的金币」。**只读、不入账**,发奖与预览共用此口径。
|
||||
|
||||
返回 `(coin, unit_count, next_unit_coin)`:
|
||||
- coin:已累计满的各份金币之和(每份 = 所在段 eCPM × LT(账号累计份序号) × 汇率)。
|
||||
- unit_count:已满份数(封顶 [FEED_MAX_UNITS])。
|
||||
- next_unit_coin:下一份(第 unit_count+1 份)的边际金币,按当前在播段(segments 末段)eCPM 算;
|
||||
已封顶或无段时为 0。供客户端小球在「本份未满」区间内做平滑插值显示。
|
||||
"""
|
||||
unit_ecpms = _walk_unit_ecpms(segments)
|
||||
unit_count = len(unit_ecpms)
|
||||
existing_units = _existing_granted_units(db, user_id)
|
||||
coin = sum(
|
||||
rewards.calculate_ad_reward_coin(ecpm, existing_units + i)
|
||||
for i, ecpm in enumerate(unit_ecpms, start=1)
|
||||
)
|
||||
if unit_count < FEED_MAX_UNITS and segments:
|
||||
next_ecpm = segments[-1][0]
|
||||
next_unit_coin = rewards.calculate_ad_reward_coin(
|
||||
next_ecpm, existing_units + unit_count + 1
|
||||
)
|
||||
).scalar_one()
|
||||
total = 0
|
||||
for offset in range(1, unit_count + 1):
|
||||
total += rewards.calculate_ad_reward_coin(ecpm, int(existing_units) + offset)
|
||||
return total
|
||||
else:
|
||||
next_unit_coin = 0
|
||||
return coin, unit_count, next_unit_coin
|
||||
|
||||
|
||||
def preview_feed_reward(
|
||||
db: Session, user_id: int, segments: list[tuple[str, int]]
|
||||
) -> dict:
|
||||
"""信息流播放中查询「此刻若结束将发放多少金币」(只读预演,不入账、不写库)。
|
||||
|
||||
给比价/领券金币小球实时显示**真实即将到账金额**用。would_status 预判若此刻结束的结算状态:
|
||||
daily_capped(已达每日条数上限,整场将发 0)/ too_short(凑不满一份)/ granted。
|
||||
"""
|
||||
coin, unit_count, next_unit_coin = compute_feed_reward(db, user_id, segments)
|
||||
if _granted_today(db, user_id, cn_today().isoformat()) >= rewards.get_ad_daily_limit(db):
|
||||
would_status = "daily_capped"
|
||||
elif unit_count == 0:
|
||||
would_status = "too_short"
|
||||
else:
|
||||
would_status = "granted"
|
||||
return {
|
||||
"coin": coin,
|
||||
"unit_count": unit_count,
|
||||
"next_unit_coin": next_unit_coin,
|
||||
"session_full": unit_count >= FEED_MAX_UNITS,
|
||||
"would_status": would_status,
|
||||
}
|
||||
|
||||
|
||||
def grant_feed_reward(
|
||||
@@ -64,8 +135,8 @@ def grant_feed_reward(
|
||||
user_id: int,
|
||||
*,
|
||||
client_event_id: str,
|
||||
ecpm: str,
|
||||
duration_seconds: int,
|
||||
segments: list[tuple[str, int]],
|
||||
ecpm_repr: str,
|
||||
ad_session_id: str | None = None,
|
||||
adn: str | None = None,
|
||||
slot_id: str | None = None,
|
||||
@@ -75,102 +146,61 @@ def grant_feed_reward(
|
||||
) -> AdFeedRewardRecord:
|
||||
"""比价/领券一整场信息流(轮播多条)结束后结算奖励。client_event_id 幂等,同号重试不重复发。
|
||||
|
||||
发奖规则:**比价全程不关广告才发**,金额按整场**总观看时长**折份(每 10 秒 1 份)。
|
||||
发奖规则:**比价全程不关广告才发**,金额按整场逐条 [segments]「跨条进位」折份
|
||||
(每 10 秒 1 份,每份用所在段 eCPM,见 [_walk_unit_ecpms])。
|
||||
- aborted=True(用户中途 ✕ 关闭):整场不发,记 status='closed_early' 留痕(原因可查)。
|
||||
- 总时长不足 10 秒(unit_count==0):记 status='too_short' 不发。
|
||||
- 命中当日条数上限:记 status='capped' 不发。
|
||||
duration_seconds 是整场累计秒数。一期 eCPM/时长均由客户端上报,故服务端两道硬闸防刷:时长钳到
|
||||
FEED_MAX_DURATION_SECONDS 限单场份数,eCPM 在 rewards.calculate_ad_reward_coin 内钳到
|
||||
AD_ECPM_MAX_FEN 限单份金额;叠加每日 get_ad_daily_limit 条数上限,把单用户日产出锁进有限区间。
|
||||
segments 是逐条广告 `(ecpm, seconds)` 时序列表(旧客户端只传总时长时,API 层包成单段)。
|
||||
ecpm_repr 是落库代表 eCPM(报表/审计用,通常取末段)。三道硬闸防刷:份数封顶 [FEED_MAX_UNITS]
|
||||
限单场份数,eCPM 在 rewards.calculate_ad_reward_coin 内钳到 AD_ECPM_MAX_FEN 限单份金额,
|
||||
叠加每日 get_ad_daily_limit 条数上限,把单用户日产出锁进有限区间。
|
||||
"""
|
||||
existing = _find_by_event(db, client_event_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
today = cn_today().isoformat()
|
||||
# 客户端上报时长先钳到 FEED_MAX_DURATION_SECONDS,防伪造超长时长刷份数(见常量注释)。
|
||||
safe_duration = max(0, min(duration_seconds, FEED_MAX_DURATION_SECONDS))
|
||||
unit_count = safe_duration // FEED_REWARD_UNIT_SECONDS
|
||||
total_seconds = sum(max(0, s) for _, s in segments)
|
||||
# 份数封顶在 compute 内做(FEED_MAX_UNITS);记录的 duration 仍钳一道,纯做报表展示防异常大值。
|
||||
safe_duration = min(total_seconds, FEED_MAX_DURATION_SECONDS)
|
||||
coin, unit_count, _next = compute_feed_reward(db, user_id, segments)
|
||||
|
||||
def _record(coin_val: int, status: str) -> AdFeedRewardRecord:
|
||||
return AdFeedRewardRecord(
|
||||
client_event_id=client_event_id,
|
||||
user_id=user_id,
|
||||
reward_date=today,
|
||||
duration_seconds=safe_duration,
|
||||
unit_count=unit_count,
|
||||
ad_session_id=ad_session_id,
|
||||
ecpm_raw=ecpm_repr,
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
app_env=app_env,
|
||||
our_code_id=our_code_id,
|
||||
coin=coin_val,
|
||||
status=status,
|
||||
)
|
||||
|
||||
# 用户中途关闭广告:整场不发(全程不关才发),留一条 closed_early 记录原因。优先级最高。
|
||||
if aborted:
|
||||
rec = AdFeedRewardRecord(
|
||||
client_event_id=client_event_id,
|
||||
user_id=user_id,
|
||||
reward_date=today,
|
||||
duration_seconds=safe_duration,
|
||||
unit_count=unit_count,
|
||||
ad_session_id=ad_session_id,
|
||||
ecpm_raw=ecpm,
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
app_env=app_env,
|
||||
our_code_id=our_code_id,
|
||||
coin=0,
|
||||
status="closed_early",
|
||||
)
|
||||
return _commit_record(db, rec, client_event_id)
|
||||
return _commit_record(db, _record(0, "closed_early"), client_event_id)
|
||||
|
||||
if _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db):
|
||||
rec = AdFeedRewardRecord(
|
||||
client_event_id=client_event_id,
|
||||
user_id=user_id,
|
||||
reward_date=today,
|
||||
duration_seconds=safe_duration,
|
||||
unit_count=unit_count,
|
||||
ad_session_id=ad_session_id,
|
||||
ecpm_raw=ecpm,
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
app_env=app_env,
|
||||
our_code_id=our_code_id,
|
||||
coin=0,
|
||||
status="capped",
|
||||
)
|
||||
return _commit_record(db, rec, client_event_id)
|
||||
return _commit_record(db, _record(0, "capped"), client_event_id)
|
||||
|
||||
# 整场总时长不足 10 秒,凑不满一份 → 不发,记 too_short 留痕。
|
||||
if unit_count == 0:
|
||||
rec = AdFeedRewardRecord(
|
||||
client_event_id=client_event_id,
|
||||
user_id=user_id,
|
||||
reward_date=today,
|
||||
duration_seconds=safe_duration,
|
||||
unit_count=0,
|
||||
ad_session_id=ad_session_id,
|
||||
ecpm_raw=ecpm,
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
app_env=app_env,
|
||||
our_code_id=our_code_id,
|
||||
coin=0,
|
||||
status="too_short",
|
||||
)
|
||||
return _commit_record(db, rec, client_event_id)
|
||||
return _commit_record(db, _record(0, "too_short"), client_event_id)
|
||||
|
||||
coin = _unit_reward_total(db, user_id, ecpm, unit_count)
|
||||
if coin > 0:
|
||||
crud_wallet.grant_coins(
|
||||
db, user_id, coin,
|
||||
biz_type="feed_ad_reward", ref_id=client_event_id,
|
||||
remark=f"信息流广告奖励 {unit_count}份",
|
||||
)
|
||||
rec = AdFeedRewardRecord(
|
||||
client_event_id=client_event_id,
|
||||
user_id=user_id,
|
||||
reward_date=today,
|
||||
duration_seconds=safe_duration,
|
||||
unit_count=unit_count,
|
||||
ad_session_id=ad_session_id,
|
||||
ecpm_raw=ecpm,
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
app_env=app_env,
|
||||
our_code_id=our_code_id,
|
||||
coin=coin,
|
||||
status="granted",
|
||||
)
|
||||
return _commit_record(db, rec, client_event_id)
|
||||
return _commit_record(db, _record(coin, "granted"), client_event_id)
|
||||
|
||||
|
||||
def _commit_record(db: Session, rec: AdFeedRewardRecord, client_event_id: str) -> AdFeedRewardRecord:
|
||||
|
||||
@@ -90,6 +90,21 @@ def upsert_record(
|
||||
items=[it.model_dump(exclude_none=True) for it in payload.items],
|
||||
comparison_results=[r.model_dump() for r in payload.comparison_results],
|
||||
skipped_dish_names=list(payload.skipped_dish_names),
|
||||
# 客户端环境 / 性能(debug,客户端上报;旧客户端为 None)
|
||||
device_model=payload.device_model,
|
||||
device_manufacturer=payload.device_manufacturer,
|
||||
rom_vendor=payload.rom_vendor,
|
||||
rom_name=payload.rom_name,
|
||||
rom_version=payload.rom_version,
|
||||
android_version=payload.android_version,
|
||||
android_sdk=payload.android_sdk,
|
||||
app_version=payload.app_version,
|
||||
app_version_code=payload.app_version_code,
|
||||
source_app_version=payload.source_app_version,
|
||||
longitude=payload.longitude,
|
||||
latitude=payload.latitude,
|
||||
total_ms=payload.total_ms,
|
||||
step_count=payload.step_count,
|
||||
raw_payload=payload.model_dump(),
|
||||
**derived,
|
||||
)
|
||||
|
||||
@@ -26,7 +26,7 @@ def get_by_code(db: Session, code: str) -> CpsLink | None:
|
||||
|
||||
|
||||
def create_link(
|
||||
db: Session, *, group_id: int, activity_id: int, sid: str, target_url: str,
|
||||
db: Session, *, group_id: int, activity_id: int, sid: str | None, target_url: str,
|
||||
platform: str = "meituan", commit: bool = True,
|
||||
) -> CpsLink:
|
||||
"""生成唯一短码并存库。冲突重试 5 次,仍冲突用 12 位兜底(概率近 0)。"""
|
||||
@@ -50,9 +50,13 @@ def create_link(
|
||||
return link
|
||||
|
||||
|
||||
def record_click(db: Session, *, link: CpsLink, ip: str | None = None, ua: str | None = None) -> None:
|
||||
def record_click(
|
||||
db: Session, *, link: CpsLink, ip: str | None = None, ua: str | None = None,
|
||||
event_type: str = "visit",
|
||||
) -> None:
|
||||
"""记一条点击。event_type: visit(进落地页/被跳转) | copy(淘宝点了"复制口令")。"""
|
||||
db.add(CpsClick(
|
||||
link_id=link.id, group_id=link.group_id, sid=link.sid,
|
||||
link_id=link.id, group_id=link.group_id, sid=link.sid, event_type=event_type,
|
||||
ip=ip, ua=(ua[:500] if ua else None),
|
||||
))
|
||||
db.commit()
|
||||
@@ -61,7 +65,8 @@ def record_click(db: Session, *, link: CpsLink, ip: str | None = None, ua: str |
|
||||
def click_stats_by_group(
|
||||
db: Session, *, date_from: datetime | None = None, date_to: datetime | None = None,
|
||||
) -> dict[int, dict]:
|
||||
"""按 group_id 聚合点击 → {group_id: {"pv": N, "uv": M}}。uv 按 (ip, ua) 近似去重。"""
|
||||
"""按 group_id 聚合点击 → {group_id: {pv, uv, copy_pv, copy_uv}}。
|
||||
visit 计入 pv/uv,copy(淘宝复制口令) 计入 copy_pv/copy_uv;uv 按 (ip, ua) 近似去重。"""
|
||||
stmt = select(CpsClick)
|
||||
if date_from is not None:
|
||||
stmt = stmt.where(CpsClick.clicked_at >= date_from)
|
||||
@@ -70,13 +75,21 @@ def click_stats_by_group(
|
||||
clicks = list(db.execute(stmt).scalars().all())
|
||||
|
||||
agg: dict[int, dict] = {}
|
||||
seen: dict[int, set] = {}
|
||||
seen_visit: dict[int, set] = {}
|
||||
seen_copy: dict[int, set] = {}
|
||||
for c in clicks:
|
||||
a = agg.setdefault(c.group_id, {"pv": 0, "uv": 0})
|
||||
a["pv"] += 1
|
||||
s = seen.setdefault(c.group_id, set())
|
||||
a = agg.setdefault(c.group_id, {"pv": 0, "uv": 0, "copy_pv": 0, "copy_uv": 0})
|
||||
key = (c.ip, c.ua)
|
||||
if key not in s:
|
||||
s.add(key)
|
||||
a["uv"] += 1
|
||||
if c.event_type == "copy":
|
||||
a["copy_pv"] += 1
|
||||
s = seen_copy.setdefault(c.group_id, set())
|
||||
if key not in s:
|
||||
s.add(key)
|
||||
a["copy_uv"] += 1
|
||||
else:
|
||||
a["pv"] += 1
|
||||
s = seen_visit.setdefault(c.group_id, set())
|
||||
if key not in s:
|
||||
s.add(key)
|
||||
a["uv"] += 1
|
||||
return agg
|
||||
|
||||
+49
-4
@@ -120,19 +120,40 @@ class TestGrantOut(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class FeedSegmentIn(BaseModel):
|
||||
"""信息流轮播中的**一条广告段**:自带 eCPM + 观看秒数。
|
||||
|
||||
新客户端按条上报(每条 eCPM 不同),服务端按时序「跨条进位」每满 10 秒结算一份、每份用所在段
|
||||
eCPM 计价(见 ad_feed_reward._walk_unit_ecpms)。
|
||||
"""
|
||||
|
||||
ecpm: str = Field(..., description="该条广告 eCPM(分/千次展示,SDK getEcpm 原值,非元)")
|
||||
seconds: int = Field(..., ge=0, description="该条广告的观看秒数")
|
||||
|
||||
|
||||
class FeedRewardIn(BaseModel):
|
||||
"""比价/领券一整场信息流(轮播多条)结束后结算奖励。
|
||||
|
||||
规则:全程不关广告才发,金额按整场**总观看时长**折份(每 10 秒 1 份)。client_event_id 用于
|
||||
客户端超时重试幂等。中途被用户关闭时传 aborted=True,整场不发(只记 closed_early)。
|
||||
规则:全程不关广告才发,金额按整场折份(每 10 秒 1 份)。client_event_id 用于客户端超时重试
|
||||
幂等。中途被用户关闭时传 aborted=True,整场不发(只记 closed_early)。
|
||||
|
||||
计价口径:优先用 [segments] 逐条精确结算(每条自带 eCPM);未传 segments 时回退用
|
||||
ecpm + duration_seconds 当**单段**(旧客户端兼容)。两者都没有则 422。
|
||||
"""
|
||||
|
||||
client_event_id: str = Field(..., min_length=8, max_length=64, description="客户端生成的幂等事件 id")
|
||||
ad_session_id: str | None = Field(
|
||||
None, min_length=8, max_length=64, description="客户端生成的一次信息流广告会话 id"
|
||||
)
|
||||
ecpm: str = Field(..., description="本场信息流 eCPM(代表值,按分/千次展示处理;SDK getEcpm 原值,非元)")
|
||||
duration_seconds: int = Field(..., ge=0, description="整场累计观看秒数(轮播各条相加)")
|
||||
segments: list[FeedSegmentIn] | None = Field(
|
||||
None, description="逐条广告段(每条自带 eCPM + 观看秒数,时序)。新客户端传它做按条精确结算"
|
||||
)
|
||||
ecpm: str | None = Field(
|
||||
None, description="[旧客户端/回退] 本场信息流代表 eCPM(分/千次);传了 segments 时仅作落库代表值"
|
||||
)
|
||||
duration_seconds: int = Field(
|
||||
0, ge=0, description="[旧客户端/回退] 整场累计观看秒数;传了 segments 时忽略"
|
||||
)
|
||||
adn: str | None = Field(None, description="实际投放 ADN")
|
||||
slot_id: str | None = Field(None, description="实际展示代码位")
|
||||
app_env: str | None = Field(
|
||||
@@ -154,6 +175,30 @@ class FeedRewardOut(BaseModel):
|
||||
daily_limit: int = Field(..., description="每日信息流展示次数上限")
|
||||
|
||||
|
||||
class FeedPreviewIn(BaseModel):
|
||||
"""信息流播放中查询「此刻若结束将发放多少金币」(只读预演,不入账)。
|
||||
|
||||
给比价/领券金币小球实时显示**真实即将到账金额**用。segments 含在播那条的部分秒数。
|
||||
"""
|
||||
|
||||
segments: list[FeedSegmentIn] = Field(
|
||||
..., min_length=1, description="截至此刻的逐条广告段(时序;含在播那条的部分秒数)"
|
||||
)
|
||||
|
||||
|
||||
class FeedPreviewOut(BaseModel):
|
||||
coin: int = Field(..., description="此刻结束将发放的累计金币(已满 10 秒的各份之和)")
|
||||
unit_count: int = Field(..., description="已累计满的份数(封顶 12)")
|
||||
next_unit_coin: int = Field(
|
||||
..., description="下一份(第 unit_count+1 份)的金币值,按当前在播段 eCPM 算;封顶后为 0。"
|
||||
"供小球在本份未满区间内做平滑插值显示"
|
||||
)
|
||||
session_full: bool = Field(..., description="是否已达单场份数上限(12),小球应停涨")
|
||||
would_status: str = Field(
|
||||
..., description="若此刻结束的结算预判:granted / too_short / daily_capped"
|
||||
)
|
||||
|
||||
|
||||
class RewardNoShowIn(BaseModel):
|
||||
"""激励视频展示了但用户提前关闭/跳过、未触发发奖——上报留痕(不发金币)。
|
||||
|
||||
|
||||
@@ -69,6 +69,10 @@ class ComparisonRecordIn(BaseModel):
|
||||
# 明细
|
||||
items: list[ComparisonItemIn] = Field(default_factory=list)
|
||||
comparison_results: list[ComparisonResultIn] = Field(default_factory=list)
|
||||
# 逐平台结局摘要(含失败平台的细分原因 status: store_not_found/items_not_found/below_minimum/
|
||||
# unsupported/...)。来自 done.params.platform_results,客户端透传;落 raw_payload(不单列),
|
||||
# admin「卡在哪一步」从这里读。list[dict] 宽松存(结构由 pricebot 定,只作 debug 展示)。
|
||||
platform_results: list = Field(default_factory=list)
|
||||
skipped_dish_count: int | None = None
|
||||
skipped_dish_names: list[str] = Field(default_factory=list)
|
||||
total_dish_count: int | None = None
|
||||
@@ -82,6 +86,23 @@ class ComparisonRecordIn(BaseModel):
|
||||
# 时分秒前端拼不出,必须由后端透传)。
|
||||
trace_url: str | None = Field(None, description="本次比价公网调试链接")
|
||||
|
||||
# ===== debug 维度(客户端采集上报;旧客户端不带 → None。仅 admin 比价记录页用)=====
|
||||
# 必须显式声明,否则 model_dump() 落 raw_payload 时被 pydantic 静默丢弃(同上面 coupon_saved 的坑)。
|
||||
device_model: str | None = None
|
||||
device_manufacturer: str | None = None
|
||||
rom_vendor: str | None = Field(None, description="vivo/OPPO/Xiaomi/HUAWEI/HONOR/samsung")
|
||||
rom_name: str | None = Field(None, description="OriginOS/ColorOS/HyperOS/MIUI/HarmonyOS...")
|
||||
rom_version: int | None = None
|
||||
android_version: str | None = None
|
||||
android_sdk: int | None = None
|
||||
app_version: str | None = None
|
||||
app_version_code: int | None = None
|
||||
source_app_version: str | None = Field(None, description="被操控的源平台 App 版本")
|
||||
longitude: float | None = None
|
||||
latitude: float | None = None
|
||||
total_ms: int | None = Field(None, description="整场比价墙钟耗时(ms)")
|
||||
step_count: int | None = Field(None, description="agent loop 总步数")
|
||||
|
||||
|
||||
# ===== 读取出参 =====
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""按 trace_id 同机拉 pricebot 的本次比价 LLM 调用明细(供 admin 比价记录 debug)。
|
||||
|
||||
pricebot 在 chat() 收口处把每次 LLM input/output 落盘,暴露内部接口
|
||||
GET /api/internal/llm_calls/{trace_id}(X-Internal-Secret 校验,与本服务 INTERNAL_API_SECRET 同值)。
|
||||
app-server 收到比价记录上报后同机拉一次,落 comparison_record.llm_calls。
|
||||
|
||||
best-effort:拉不到(pricebot 未部署该版本 / 旧 trace 无明细 / 网络)一律返 [],绝不阻断
|
||||
比价记录上报。多实例下用 pick_pricebot(trace_id) 选对实例(同 trace 落同进程,明细在
|
||||
该进程磁盘),与比价透传同一致性 hash 口径。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.pricebot_router import pick_pricebot
|
||||
|
||||
logger = logging.getLogger("shagua.pricebot_llm")
|
||||
|
||||
|
||||
def fetch_llm_calls(trace_id: str) -> list[dict]:
|
||||
"""返回该次比价的 LLM 调用明细列表(每条 {scene,model,input_messages,output,usage,latency_ms,error});
|
||||
未配密钥 / 无 trace_id / 拉取失败 → []。"""
|
||||
secret = settings.INTERNAL_API_SECRET
|
||||
if not secret or not trace_id:
|
||||
return []
|
||||
base = pick_pricebot(trace_id).rstrip("/")
|
||||
url = f"{base}/api/internal/llm_calls/{trace_id}"
|
||||
try:
|
||||
resp = httpx.get(url, headers={"X-Internal-Secret": secret}, timeout=5.0)
|
||||
if resp.status_code == 200:
|
||||
return resp.json().get("calls", []) or []
|
||||
logger.warning("fetch_llm_calls trace=%s status=%s", trace_id, resp.status_code)
|
||||
except Exception as e: # noqa: BLE001 — best-effort,任何异常都不该影响上报
|
||||
logger.warning("fetch_llm_calls trace=%s failed: %s", trace_id, e)
|
||||
return []
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 239 KiB |
+15
-9
@@ -1,16 +1,22 @@
|
||||
# 后端文档库(docs/)
|
||||
|
||||
`shaguabijia-app-server` 的文档都在这里。各文档作用:
|
||||
`shaguabijia-app-server` 的文档都在这里。结构:**根目录放总览,其余按领域/用途分目录**。
|
||||
|
||||
| 文档 / 目录 | 作用 |
|
||||
## 根目录
|
||||
|
||||
| 文档 | 作用 |
|
||||
|---|---|
|
||||
| [后端技术实现.md](./后端技术实现.md) | **后端技术方案**:业务概览、分层架构与目录、登录链路、美团 CPS、领券透传、数据模型、配置与部署、已知问题。想了解"整个后端怎么回事"看这份。 |
|
||||
| [api/](./api/) | **API 接口文档**(目录),采用"索引 + 一接口一文件",结构见下。想查"某个接口的协议"看这里。 |
|
||||
| [integrations/](./integrations/) | **集成层实现文档**(目录):穿山甲验签 / 微信支付 / 极光 / 短信 / 美团 CPS 等 SDK 集成的签名、加解密、协议细节与踩坑。想知道"接外部服务那块到底怎么实现"看这里。 |
|
||||
| [数据库迁移.md](./数据库迁移.md) | **Alembic 迁移指南**:clone 后如何建表、日常升级、新增迁移、迁移文件命名约定。想"把数据库跑起来 / 改表结构"看这份。 |
|
||||
| [待办与技术债.md](./待办与技术债.md) | **待办与技术债账本**:记"现在先简化、以后要补"的事 + 跨前后端技术债(P1 鉴权/用户绑定、引擎移植待办等),免遗忘。想知道"还欠什么、以后要补什么"看这份。 |
|
||||
| [看广告赚金币上线清单.md](./看广告赚金币上线清单.md) | **看广告发奖上线 checklist**:跨前后端,记上线前必做(GroMore 回调配置、清理调试脚手架、端到端验收)。上线"看广告赚金币"前对照这份。 |
|
||||
| [邀请功能-实现原理与本地测试.md](./邀请功能-实现原理与本地测试.md) | **好友邀请(invite-mvp)实现原理 + 跨前后端具体实现 + 本地内网测试方法**:剪贴板 deferred-deeplink 归因、双方各发 1 万金币、落地页 dl.html、起本地后端 + 编 debug 包 + 两台真机走全链路 + 上线前还差什么。要本地测 / 接手邀请功能看这份。 |
|
||||
| [后端技术实现.md](./后端技术实现.md) | **后端技术方案总览**:业务概览、分层架构与目录、登录链路、美团 CPS、领券/比价透传、数据模型、配置与部署、已知问题。想了解"整个后端怎么回事"先看这份。 |
|
||||
| README.md | 本文件:文档库索引/传送门。 |
|
||||
|
||||
## 子目录
|
||||
|
||||
| 目录 | 作用 |
|
||||
|---|---|
|
||||
| [api/](./api/) | **API 接口文档**:"索引 + 一接口一文件"(组织方式见下)。想查"某个接口的协议"看这里。 |
|
||||
| [database/](./database/) | **数据库文档**:每张表一个文件(表结构/字段/索引/约定) + 两份迁移指南——[数据库迁移.md](./database/数据库迁移.md)(Alembic 操作:clone 后建表/日常升级/新增迁移/多 head 排查) + [postgres-migration.md](./database/postgres-migration.md)(SQLite → PostgreSQL 切换步骤,配套 `scripts/init_postgres.py`)。想"把库跑起来 / 改表结构 / 查某张表"看这里。 |
|
||||
| [integrations/](./integrations/) | **集成层实现文档**:穿山甲验签 / 微信支付 / 极光 / 短信 / 美团 CPS 等 SDK 集成的签名、加解密、协议细节与踩坑。想知道"接外部服务那块到底怎么实现"看这里。 |
|
||||
| [guides/](./guides/) | **开发 / 上线 / 功能 指南**:[待办与技术债.md](./guides/待办与技术债.md)(跨前后端 backlog,P1 鉴权/用户绑定、引擎移植待办等,"还欠什么、以后要补什么"看这份) + [看广告赚金币上线清单.md](./guides/看广告赚金币上线清单.md)(看广告发奖上线 checklist) + [邀请功能-实现原理与本地测试.md](./guides/邀请功能-实现原理与本地测试.md)(invite-mvp 实现原理 + 本地内网全链路测试)。 |
|
||||
|
||||
## api/ 目录是怎么组织的(传送门式)
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
| 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad-test-grant.md) |
|
||||
| 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad-ecpm-report.md) |
|
||||
| 35a | `POST /api/v1/ad/feed-reward` | Bearer | [详情](./ad-feed-reward.md) |
|
||||
| 35a' | `POST /api/v1/ad/feed-reward/preview` | Bearer | [详情](./ad-feed-reward-preview.md)(播放中预演将发放金币,只读不入账,金币小球用) |
|
||||
| 35b | `POST /api/v1/ad/reward-noshow` | Bearer | [详情](./ad-reward-noshow.md)(激励视频提前关闭/未发奖留痕,只记原因不发币) |
|
||||
| **用户资料**(前缀 `/api/v1/user`) |||
|
||||
| 35 | `PATCH /api/v1/user/profile` | Bearer | [详情](./user-profile.md) |
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# POST /api/v1/ad/feed-reward/preview — 信息流播放中预演将发放的金币(只读)
|
||||
|
||||
比价 / 领券信息流广告**播放过程中**,查询「若此刻结束、整场将发放多少金币」。给金币小球实时显示**真实即将到账金额**用:与 [feed-reward](./ad-feed-reward.md) **同一计价口径**(同份数封顶 / 同 LT / 同 eCPM 钳),但**只读、不入账、不写库**。
|
||||
|
||||
客户端每满 10 秒观看(或换条 eCPM 变化)拉一次;两次之间用 `next_unit_coin × 本份进度` 在本地做平滑插值显示。
|
||||
|
||||
## 鉴权
|
||||
|
||||
需要 Bearer token。
|
||||
|
||||
## 请求体
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---:|---|
|
||||
| `segments` | array | 是 | 截至此刻的**逐条广告段**(时序,至少 1 项;含在播那条的部分秒数),每项 `{ecpm: string, seconds: int}` |
|
||||
|
||||
## 响应
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `coin` | int | 此刻结束将发放的累计金币(已满 10 秒的各份之和) |
|
||||
| `unit_count` | int | 已累计满的份数(封顶 12) |
|
||||
| `next_unit_coin` | int | 下一份(第 `unit_count+1` 份)的金币值,按当前在播段 eCPM 算;封顶后为 0。供小球在本份未满区间内平滑插值 |
|
||||
| `session_full` | bool | 是否已达单场份数上限(12)→ 小球应停涨 |
|
||||
| `would_status` | string | 若此刻结束的结算预判:`granted` / `too_short`(凑不满一份) / `daily_capped`(已达每日条数上限,整场将发 0) |
|
||||
|
||||
## 与 feed-reward 的关系
|
||||
|
||||
- 同样按 `segments` 跨条进位每满 10 秒一份、每份用所在段 eCPM 计价(详见 [feed-reward 计算口径](./ad-feed-reward.md#计算口径))。
|
||||
- preview 的 `coin` 等于「用相同 segments 调 feed-reward 实发的 `coin`」——小球显示值即整场结束实发额(除非中途 `would_status` 提示 `daily_capped`,那种情况整场实发 0)。
|
||||
- preview **不消耗每日上限、不写任何表**,可高频调用(限流 600/分)。
|
||||
|
||||
## 限流
|
||||
|
||||
600 次 / 分钟(`ad-feed-preview`)。
|
||||
+19
-10
@@ -1,6 +1,8 @@
|
||||
# POST /api/v1/ad/feed-reward — 信息流广告完成后结算金币
|
||||
|
||||
点位 2:比价等待 / 领券信息流广告(轮播多条)。**整场比价全程不关广告才发**,金额按整场**总观看时长**折份(每 10 秒 1 份),结束时一次性入账。用户中途 ✕ 关闭则整场不发。
|
||||
点位 2:比价等待 / 领券信息流广告(轮播多条)。**整场比价全程不关广告才发**,金额按整场折份(每 10 秒 1 份),结束时一次性入账。用户中途 ✕ 关闭则整场不发。
|
||||
|
||||
播放过程中查询「此刻将发放多少金币」(金币小球实时显示用)见 [feed-reward/preview](./ad-feed-reward-preview.md),与本接口同一计价口径但只读不入账。
|
||||
|
||||
## 鉴权
|
||||
|
||||
@@ -8,37 +10,44 @@
|
||||
|
||||
## 请求体
|
||||
|
||||
计价优先用 `segments` 逐条精确结算;未传 `segments` 时回退用 `ecpm` + `duration_seconds` 当**单段**(旧客户端兼容)。两者都不传 → 422。
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---:|---|
|
||||
| `client_event_id` | string | 是 | 客户端生成的幂等事件 id,8-64 字符 |
|
||||
| `ad_session_id` | string\|null | 否 | 客户端生成的一次信息流广告会话 id,用于对账/排查 |
|
||||
| `ecpm` | string | 是 | 本场信息流 eCPM 代表值(穿山甲 getEcpm 原值),按“分/千次展示”处理(非元) |
|
||||
| `duration_seconds` | int | 是 | **整场累计观看秒数**(轮播各条相加) |
|
||||
| `segments` | array\|null | 否* | **逐条广告段**(时序),每项 `{ecpm: string, seconds: int}`。新客户端传它做按条精确结算 |
|
||||
| `ecpm` | string\|null | 否* | **[回退]** 本场代表 eCPM(穿山甲 getEcpm 原值,分/千次展示,非元);传了 `segments` 时仅作落库代表值 |
|
||||
| `duration_seconds` | int | 否 | **[回退]** 整场累计观看秒数;传了 `segments` 时忽略。默认 0 |
|
||||
| `adn` | string\|null | 否 | 实际投放 ADN(聚合后实际填充的子渠道) |
|
||||
| `slot_id` | string\|null | 否 | 实际展示代码位(底层 mediation rit) |
|
||||
| `app_env` | string\|null | 否 | **我们的**应用环境:`prod`(傻瓜比价正式) / `test`(测试应用) |
|
||||
| `our_code_id` | string\|null | 否 | **我们后台配置的**代码位 ID(104xxx,非底层 rit);供广告收益报表按代码位聚合金币 |
|
||||
| `aborted` | bool | 否 | 用户中途 ✕ 关闭广告(未走完比价):整场不发,仅记 `closed_early`。默认 `false` |
|
||||
|
||||
\* `segments` 与 `ecpm` 至少传其一。
|
||||
|
||||
## 响应
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `granted` | bool | 本次是否入账;未发(任一非 granted 状态)时为 `false` |
|
||||
| `status` | string | `granted`(已发) / `capped`(当日次数超限) / `too_short`(整场总时长<10s 凑不满一份) / `closed_early`(用户中途关闭) |
|
||||
| `status` | string | `granted`(已发) / `capped`(当日次数超限) / `too_short`(整场<10s 凑不满一份) / `closed_early`(用户中途关闭) |
|
||||
| `coin` | int | 本次发放金币;非 granted 为 0 |
|
||||
| `unit_count` | int | 按 10 秒折算出的奖励份数 |
|
||||
| `unit_count` | int | 折算出的奖励份数(封顶 12) |
|
||||
| `daily_limit` | int | 每日信息流展示次数上限,默认 500 |
|
||||
|
||||
## 计算口径
|
||||
|
||||
- 奖励份数:`整场总时长 // 10`。
|
||||
- 单份奖励:`eCPM / 1000 × 因子1(eCPM 档) × 因子2(当天累计份序号) × 10000`,四舍五入为整数金币。
|
||||
- eCPM 档:`0-100=0.1`,`101-200=0.3`,`201-400=0.4`,`>400=0.6`。
|
||||
- LT 档:第 1 份 `2.0`,第 2 份 `1.5`,第 3 份 `1.3`,第 4-10 份 `1.1`,第 11 份及以后 `1.0`。
|
||||
- **奖励份数(跨条进位)**:按 `segments` 时序遍历,维护一个连续秒数计数器,每满 10 秒结算一份;换条时不足一份的零头进位到下一条。份数封顶 **12 份/场**(防刷硬闸,替代旧的「单事件时长钳 120s」)。
|
||||
- **每份归属的 eCPM**:该份「满 10 秒那一刻正在播的那条广告」的 eCPM。
|
||||
- **单份奖励**:`eCPM / 1000 × 因子1(eCPM 档) × 因子2(账号累计份序号) × 10000`,四舍五入为整数金币。
|
||||
- eCPM 档:`0-100=0.1`,`101-200=0.3`,`201-400=0.4`,`>400=0.6`(eCPM 钳顶 ¥500 CPM)。
|
||||
- LT 档(按**账号累计已发份数**,不按天重置):第 1 份 `2.0`,第 2 份 `1.5`,第 3 份 `1.3`,第 4-10 份 `1.1`,第 11 份及以后 `1.0`。
|
||||
- 旧客户端单段口径 `[(ecpm, duration_seconds)]` 是上述的特例,份数 = `min(duration//10, 12)`,结果与改造前一致。
|
||||
|
||||
## 数据写入
|
||||
|
||||
- `ad_feed_reward_record` 新增一行。
|
||||
- `ad_feed_reward_record` 新增一行(`ecpm_raw` 存代表 eCPM,通常为末段)。
|
||||
- 入账时 `coin_account` 增加余额。
|
||||
- 入账时 `coin_transaction` 写入 `biz_type=feed_ad_reward`。
|
||||
|
||||
@@ -18,7 +18,7 @@ pricebot-backend 的响应**原样返回**(JSON object)。典型含 `result`
|
||||
|
||||
外卖比价由客户端无障碍引擎在源平台(淘宝闪购 / 美团 / 京东外卖)购物车页点悬浮球触发 → 调本接口拿 `query` + `calibration` → 进入 `/price/step` 循环。
|
||||
|
||||
⚠️ **MVP 阶段不鉴权**(同 `coupon/step`):`device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 行为暂绑不到登录用户。待补 JWT,见 [待办与技术债.md](../待办与技术债.md) P1。
|
||||
⚠️ **MVP 阶段不鉴权**(同 `coupon/step`):`device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 行为暂绑不到登录用户。待补 JWT,见 [待办与技术债.md](../guides/待办与技术债.md) P1。
|
||||
|
||||
**相关配置**:
|
||||
- `PRICEBOT_BASE_URL`(默认 `http://localhost:8000`)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
比价 `done` 帧后,客户端用**带 JWT 的通道**上报一条比价结果,落 `comparison_record` 表,作为「我的比价记录」数据源 + 用户级行为画像。
|
||||
|
||||
> ⚠️ 与不鉴权的透传端点 [`/api/v1/price/step`](./compare-price-step.md) 不同:那是转发壳,本接口按用户维度落库,**必须鉴权**。
|
||||
> 本轮只做 server 端;客户端在 done 帧后调本接口的改动另起一轮(见 [待办与技术债.md](../待办与技术债.md) P1)。
|
||||
> 本轮只做 server 端;客户端在 done 帧后调本接口的改动另起一轮(见 [待办与技术债.md](../guides/待办与技术债.md) P1)。
|
||||
|
||||
## 入参(JSON body)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ pricebot-backend 的响应**原样返回**(JSON object)。
|
||||
|
||||
是产品"一键领券"的接入点,**前端已接通**:首页「去领取」→ `CouponPromptDialog` 确认 → 权限检查 → 无障碍引擎 `PriceBotService.startCouponClaim()` → 循环调本接口,后端逐张券下发 launch/wait/done。
|
||||
|
||||
⚠️ **客户端契约 vs Server 实现**:Android 客户端通过 `AuthInterceptor` 已自动带 `Authorization: Bearer <access_token>` 头(契约层 OK);Server MVP 阶段**暂未启用强校验**——[coupon.py](../../app/api/v1/coupon.py) 没接 `Depends(get_current_user)`,只读 `device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 领券行为暂时绑不到登录用户(采集不到用户级画像)。待补强校验 + `device_id↔user_id` 绑定,详见 [待办与技术债.md](../待办与技术债.md) P1。
|
||||
⚠️ **客户端契约 vs Server 实现**:Android 客户端通过 `AuthInterceptor` 已自动带 `Authorization: Bearer <access_token>` 头(契约层 OK);Server MVP 阶段**暂未启用强校验**——[coupon.py](../../app/api/v1/coupon.py) 没接 `Depends(get_current_user)`,只读 `device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 领券行为暂时绑不到登录用户(采集不到用户级画像)。待补强校验 + `device_id↔user_id` 绑定,详见 [待办与技术债.md](../guides/待办与技术债.md) P1。
|
||||
|
||||
**相关配置**:
|
||||
- `PRICEBOT_BASE_URL`(默认 `http://localhost:8000`)
|
||||
|
||||
@@ -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、**无关系库**。共 **28 张业务表** + `alembic_version`(框架的迁移版本指针)。领券联动的「今日状态」三张表(`coupon_*`)同理:领券过程在 pricebot 内存态跑、**不落库**,只有结果回到 app-server 才落这三张表。
|
||||
> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **37 张业务表** + `alembic_version`(框架的迁移版本指针)。注意「比价/领券**过程**」始终在 pricebot 内存态跑、**不落库**——只有**结果**回 app-server 才落库:领券结果落 `coupon_*` 三张今日状态表;比价结果分两路——客户端带 JWT 上报「我的记录」落 `comparison_record`,pricebot 另经 `app/api/internal/` server→server 把客观价格/门店事实落 `price_observation`/`store_mapping`(不鉴权、匿名也记)。此外好友邀请(`invite_*` 2 张)与美团 CPS 群发联盟(`cps_*` 5 张)是两个独立子系统。
|
||||
|
||||
---
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
| 比价战绩里程碑(逐档领金币) | [`comparison_milestone_claim`](./comparison_milestone_claim.md) | 累计成功比价 N 次解锁;进度读 `comparison_record` 计数 |
|
||||
| profile「累计省了 / 省钱战绩 / 省钱明细」 | [`savings_record`](./savings_record.md) | 真实下单归因(source=compare)+ 无真实数据时 demo 兜底 |
|
||||
| 「上报更低价」提交 / 列表 | [`price_report`](./price_report.md) | 众包纠偏:用户举证某平台更便宜,人工审核发奖 |
|
||||
| (无 App UI)比价 done 后客观价格沉淀 | [`price_observation`](./price_observation.md) | **pricebot server→server 内部上报**;平台/门店视角的到手价事实,匿名也记,与 `comparison_record`(用户视角)互补 |
|
||||
| (无 App UI)跨平台门店身份映射 | [`store_mapping`](./store_mapping.md) | **pricebot 内部上报**;同店在淘宝/美团/京东的 id/名/deeplink,供下次比价 `lookup` 反查省掉现场搜店 |
|
||||
|
||||
### 领券(每日领券联动 · 今日状态)
|
||||
| App 位置 / 动作 | 表 | 说明 |
|
||||
@@ -31,7 +33,7 @@
|
||||
| 资产卡 / 钱包余额 | [`coin_account`](./coin_account.md) | 一用户一行的金币+现金余额快照 |
|
||||
| 金币明细 | [`coin_transaction`](./coin_transaction.md) | 每次金币变动一笔流水 |
|
||||
| 现金明细 | [`cash_transaction`](./cash_transaction.md) | 每次现金变动一笔流水(分) |
|
||||
| 每日签到 | [`signin_record`](./signin_record.md) + [`signin_boost_record`](./signin_boost_record.md) | 14 天循环发币;签到后看广告可膨胀一次 |
|
||||
| 每日签到 | [`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 秒累计一份奖励,完成后一次性入账 |
|
||||
@@ -46,6 +48,21 @@
|
||||
| 新手引导是否再展示 | [`onboarding_completion`](./onboarding_completion.md) | 按 设备+账号 去重;登录响应回 `onboarding_completed`,走完引导时标记,跨卸载重装 |
|
||||
| 帮助与反馈 | [`feedback`](./feedback.md) | 含截图,后台人工处理 |
|
||||
|
||||
### 好友邀请(注册增长)
|
||||
| App 位置 / 动作 | 表 | 说明 |
|
||||
|---|---|---|
|
||||
| 输入/剪贴板邀请码绑定 | [`invite_relation`](./invite_relation.md) | 注册即生效,邀请人+被邀请人各发 1 万金币;`invitee_user_id` 唯一=幂等防重复发奖 |
|
||||
| 落地页访问指纹(剪贴板归因兜底) | [`invite_fingerprint`](./invite_fingerprint.md) | 剪贴板没拿到码时,用 (ip+机型+屏幕) 7 天内反查邀请人 |
|
||||
|
||||
### 美团 CPS 群发联盟(私域社群比价,运营后台驱动 · 群发选品→点击→对账漏斗)
|
||||
| 后台/用户动作 | 表 | 说明 |
|
||||
|---|---|---|
|
||||
| 运营建可推广活动 | [`cps_activity`](./cps_activity.md) | 预存券/物料(美团 actId / 淘宝淘口令 / 京东链接) |
|
||||
| 运营建微信推广群 | [`cps_group`](./cps_group.md) | 一群一行,`sid`=美团二级渠道追踪位 |
|
||||
| 后台批量生成群发短链 | [`cps_link`](./cps_link.md) | 群×活动 → `/c/{code}`,运营复制到微信群 |
|
||||
| 用户点短链(落地/复制口令) | [`cps_click`](./cps_click.md) | 记 visit/copy 事件,统计 PV/UV |
|
||||
| 定时拉美团联盟订单对账 | [`cps_order`](./cps_order.md) | `query_order` 按 sid 归群,串成点击→下单→佣金漏斗 |
|
||||
|
||||
### 运营后台 admin(独立子应用 `app/admin/`,端口 8771,独立鉴权)
|
||||
| 后台模块 | 表 | 说明 |
|
||||
|---|---|---|
|
||||
@@ -84,7 +101,9 @@
|
||||
| 首次进 profile 省钱页且无真实记录 | `savings_record`(C `source=demo`) | 懒种子,`ensure_seeded` 按 user 幂等 |
|
||||
| 上报更低价 `POST /report` | `price_report`(C) | 读 `comparison_record.best_price_cents` 校验 |
|
||||
| 提交反馈 `POST /feedback` | `feedback`(C) | |
|
||||
| 领券首帧 `POST /api/v1/coupon/step`(step=0) | `coupon_prompt_engagement`(C/U `claim_started`) | `(device_id, 北京日)` 幂等;best-effort |
|
||||
| 绑定邀请 `POST /invite/bind` | `invite_relation`(C `effective`) + `coin_account`(U×2) + `coin_transaction`(C `invite_inviter` + `invite_invitee`) | 同事务;`invitee_user_id` 唯一幂等,双方各发 1 万金币 |
|
||||
| 落地页归因 `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 |
|
||||
| 领券跑完 `POST /api/v1/coupon/step`(action.command=done) | `coupon_daily_completion`(C/U) | `(device_id, 北京日)` 幂等;best-effort |
|
||||
| 拒绝领券引导窗 `POST /api/v1/coupon/prompt/dismiss` | `coupon_prompt_engagement`(C/U `dismissed`) | 同上;客户端通知(透传链路看不到拒绝) |
|
||||
@@ -103,7 +122,19 @@
|
||||
| 改运营配置 | `app_config`(C/U)+`admin_audit_log`(C) | 同事务 |
|
||||
| 任意写操作 | `admin_audit_log`(C,**永不 U/D**) | |
|
||||
|
||||
> 没有任何表会被业务流程物理 DELETE。注销是软删(改 user 行),其余只 C/U。
|
||||
> 没有任何表会被业务流程物理 DELETE。注销是软删(改 user 行),其余只 C/U(领券 `/prompt/reset`、`/completed-today/reset` 是开发用删除,非业务流程)。
|
||||
|
||||
### CPS 群发联盟 & pricebot 内部上报(非 C 端用户、非 admin 常规管理触发)
|
||||
| 触发 | 写入 | 操作 |
|
||||
|---|---|---|
|
||||
| 运营后台建活动 / 建群 | `cps_activity`(C/U) / `cps_group`(C/U) | `app/admin/routers/cps.py`;群含美团平台才分配 `sid` |
|
||||
| 后台批量生成短链 `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 |
|
||||
| 定时拉美团联盟订单对账 | `cps_order`(C/U upsert) | `query_order` 按 `sid` 归群;`order_id` 幂等(状态会变,重复拉则更新) |
|
||||
| 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 失效标记(淘宝/京东) |
|
||||
|
||||
> CPS 5 表与本 App `user` **无关**(被推广群的用户在美团下单,不是本 App 注册用户);`price_observation`/`store_mapping` 的 `source_user_id` 是可空旁路(链路不鉴权,匿名也记)。
|
||||
|
||||
---
|
||||
|
||||
@@ -113,6 +144,8 @@
|
||||
- **17 张用户维度表 `.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`。
|
||||
- `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`。
|
||||
- **CPS 5 表 + 比价沉淀 2 表均无硬 FK**(全靠 `sid`/`trace_id` 语义对齐,见下)。
|
||||
|
||||
### 语义 join key(无 FK 约束,靠业务字段对齐 —— 排障/对账必看)
|
||||
- **`coin_transaction.ref_id` 指向随 `biz_type` 变**:
|
||||
@@ -139,6 +172,8 @@
|
||||
- **里程碑解锁进度不存库**:`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))。
|
||||
- **`onboarding_completion.(user_id, device_id)`**:`user_id` 语义关联 `user.id`(无硬 FK,同 `coupon_*` 设备表),`device_id` = 客户端硬件级 `ANDROID_ID`(≠ 领券 per-install `device_id`)。登录读、走完引导写,决定是否再展示新手引导。
|
||||
- **CPS 群发 5 表全靠 `sid` / id 语义串联(无硬 FK)**:`cps_link.group_id`→`cps_group.id`、`cps_link.activity_id`→`cps_activity.id`、`cps_click.link_id`→`cps_link.id`;**点击与订单无法对到单笔**,只在群维度(`sid`)汇合——`cps_order.sid` ≈ `cps_group.sid` ≈ `cps_link.sid` ≈ `cps_click.sid`(仅美团有 sid,淘宝/京东无)。统计按群聚合,故 `group_id`/`sid` 冗余进 `cps_click` 免 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。
|
||||
|
||||
### ER 关系(文字版)
|
||||
```
|
||||
@@ -154,6 +189,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 仅软关联)
|
||||
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
|
||||
(CPS 5 表自成子系统; cps_order 经 sid 归群对账, 与本 App user 无关)
|
||||
price_observation / store_mapping (独立, 无硬 FK; 维度=trace_id, source_user/device 仅软关联; pricebot 内部上报)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+25
-4
@@ -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-11(合并:新增 3 张领券今日状态表 `coupon_*` + `onboarding_completion` 新手引导完成表;含 [OVERVIEW 总览](./OVERVIEW.md))
|
||||
> 最后更新:2026-06-17(补全 9 张此前缺文档的表:CPS 群发 5 张 `cps_*` + 好友邀请 2 张 `invite_*` + 比价沉淀 `price_observation`/`store_mapping`;并全表 review 对齐 model——`signin_record` 改 7 天循环、`coupon_prompt_engagement` 加 `package` 列按 App 频控、`user` 补 `username`/`invite_code`/`debug_trace_enabled` 等;含 [OVERVIEW 总览](./OVERVIEW.md))
|
||||
|
||||
> 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。
|
||||
|
||||
---
|
||||
|
||||
## 表总览(28 张业务表 + `alembic_version` 框架表)
|
||||
## 表总览(37 张业务表 + `alembic_version` 框架表)
|
||||
|
||||
### 账号 / 反馈
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
@@ -18,6 +18,12 @@
|
||||
| `onboarding_completion` | 新手引导完成标记(按 设备+账号 去重,登录时据此跳过引导) | `models/onboarding.py` | [详情](./onboarding_completion.md) |
|
||||
| `feedback` | 用户帮助与反馈(含截图) | `models/feedback.py` | [详情](./feedback.md) |
|
||||
|
||||
### 好友邀请(注册增长)
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
|---|---|---|---|
|
||||
| `invite_relation` | 邀请绑定关系(注册即生效,双方各发1万金币;`invitee_user_id` 唯一=幂等防重复发奖) | `models/invite.py` | [详情](./invite_relation.md) |
|
||||
| `invite_fingerprint` | 剪贴板归因失败时的指纹兜底(落地页记 ip+屏幕+机型,登录后反查邀请人) | `models/invite_fingerprint.py` | [详情](./invite_fingerprint.md) |
|
||||
|
||||
### 钱包 / 福利(看广告赚钱闭环)
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
|---|---|---|---|
|
||||
@@ -26,7 +32,7 @@
|
||||
| `cash_transaction` | 现金流水账本(分) | `models/wallet.py` | [详情](./cash_transaction.md) |
|
||||
| `withdraw_order` | 提现单(现金→微信零钱,含人工审核态) | `models/wallet.py` | [详情](./withdraw_order.md) |
|
||||
| `wechat_transfer_authorization` | 微信免确认转账授权(一用户一行) | `models/wallet.py` | [详情](./wechat_transfer_authorization.md) |
|
||||
| `signin_record` | 签到记录(14 天循环) | `models/signin.py` | [详情](./signin_record.md) |
|
||||
| `signin_record` | 签到记录(7 天循环) | `models/signin.py` | [详情](./signin_record.md) |
|
||||
| `signin_boost_record` | 签到后看广告膨胀记录 | `models/signin.py` | [详情](./signin_boost_record.md) |
|
||||
| `user_task` | 一次性任务领取去重 | `models/task.py` | [详情](./user_task.md) |
|
||||
| `ad_reward_record` | 看激励视频发奖记录(S2S 回调,trans_id 幂等) | `models/ad_reward.py` | [详情](./ad_reward_record.md) |
|
||||
@@ -42,10 +48,16 @@
|
||||
| `savings_record` | 省钱记录(profile 省钱战绩源;真实下单归因 + demo) | `models/savings.py` | [详情](./savings_record.md) |
|
||||
| `price_report` | 上报更低价(众包纠偏,人工审核发奖) | `models/price_report.py` | [详情](./price_report.md) |
|
||||
|
||||
### 比价数据沉淀(pricebot server→server 内部上报,不走用户鉴权)
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
|---|---|---|---|
|
||||
| `price_observation` | 价格观测(平台/门店视角的到手价事实,匿名也记;`comparison_record` 的客观对照) | `models/price_observation.py` | [详情](./price_observation.md) |
|
||||
| `store_mapping` | 跨平台「同一家店」id/名/deeplink 身份映射(淘宝/美团/京东,供 lookup 反查省现场搜店) | `models/store_mapping.py` | [详情](./store_mapping.md) |
|
||||
|
||||
### 领券(每日领券联动 · 今日状态)
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
|---|---|---|---|
|
||||
| `coupon_prompt_engagement` | 领券引导窗频控源(今日是否已 engage,按 device+日) | `models/coupon_state.py` | [详情](./coupon_state.md) |
|
||||
| `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) |
|
||||
|
||||
@@ -54,6 +66,15 @@
|
||||
|---|---|---|---|
|
||||
| `meituan_coupon` | 美团 CPS 券本地缓存(智能推荐/销量榜从库出,定时 ETL 灌入) | `models/meituan_coupon.py` | [详情](./meituan_coupon.md) |
|
||||
|
||||
### 美团 CPS 群发联盟(私域社群比价 · 群发选品→点击→对账漏斗)
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
|---|---|---|---|
|
||||
| `cps_activity` | 可推广活动池(运营预存的券/物料:美团 actId / 淘宝淘口令 / 京东链接) | `models/cps_activity.py` | [详情](./cps_activity.md) |
|
||||
| `cps_group` | 推广微信群(`sid`=美团二级渠道追踪位,订单按 sid 归群对账) | `models/cps_group.py` | [详情](./cps_group.md) |
|
||||
| `cps_link` | 群发短链 `/c/{code}`(群×活动生成,302 跳/淘宝落地页) | `models/cps_link.py` | [详情](./cps_link.md) |
|
||||
| `cps_click` | 短链点击事件流(visit/copy,PV/UV 漏斗源) | `models/cps_link.py` | [详情](./cps_click.md) |
|
||||
| `cps_order` | 美团联盟 query_order 拉回的对账订单(按 sid 归群算佣金) | `models/cps_order.py` | [详情](./cps_order.md) |
|
||||
|
||||
### 首页门面数据
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
|---|---|---|---|
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# ad_feed_reward_record — 信息流广告奖励记录
|
||||
|
||||
> 模型 `app/models/ad_feed_reward.py` · 仓库 `app/repositories/ad_feed_reward.py` · 接口 `POST /api/v1/ad/feed-reward`(信息流广告结算) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
点位 2 的奖励记录。每条记录对应客户端完成的一次信息流广告展示/播放事件。
|
||||
|
||||
## 字段
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
| `app_env` | String(16) | nullable | 来源应用 `prod`(傻瓜比价)/`test`(测试);S2S 不带,发奖时按 `ad_session_id` 匹配 `ad_ecpm_record` 回填,查不到 NULL。广告收益报表金币侧按它聚合 |
|
||||
| `our_code_id` | String(64) | nullable | 我们配置的代码位 104xxx(同上回填) |
|
||||
| `coin` | Integer | NOT NULL, default 0 | 实发金币;`capped`/`ecpm_missing`/`closed_early`/业务不满足时为 0 |
|
||||
| `status` | String(16) | NOT NULL, default `granted` | 取值:`granted`(已发)/ `capped`(当日次数超限)/ `ecpm_missing`(缺 eCPM)/ `closed_early`(展示了但用户提前关/跳过,未发奖,客户端 reward-noshow 留痕)/ `not_signed`/`already_boosted`/`last_day` |
|
||||
| `status` | String(16) | NOT NULL, default `granted` | 取值:`granted`(已发)/ `capped`(当日次数超限)/ `ecpm_missing`(缺 eCPM)/ `closed_early`(展示了但用户提前关/跳过,未发奖,客户端 reward-noshow 留痕)/ `not_signed`/`already_boosted`/`last_day`/ `unknown_scene`(回调 `reward_scene` 不在支持集合,只留痕不发) |
|
||||
| `reward_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它等值统计当日发奖次数 |
|
||||
| `reward_name` | String(64) | nullable | 穿山甲上报奖励名(参考,不作发奖依据) |
|
||||
| `raw` | String(1024) | nullable | 回调原始参数(审计排查) |
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `key` | String(64) | **PK** | 配置标识,取值见 `config_schema.CONFIG_DEFS`:`signin_rewards` / `min_exchange_coin` / `withdraw_min_cents` / `withdraw_max_cents` / `task_rewards` / `record_milestones` / `ad_reward_coin` / `ad_daily_limit` / `ad_max_coin` / `ad_round_count` / `ad_cooldown_sec` / `signin_boost_coin` / `withdraw_auto_reconcile_enabled` |
|
||||
| `value` | JSON(PG: JSONB) | NOT NULL | 配置值,类型随 key(`int` / `int_list` 如签到 14 档 / `dict_str_int` 如 task_rewards / `bool` 如 withdraw_auto_reconcile_enabled) |
|
||||
| `key` | String(64) | **PK** | 配置标识,取值见 `config_schema.CONFIG_DEFS`:`signin_rewards` / `min_exchange_coin` / `withdraw_min_cents` / `withdraw_max_cents` / `task_rewards` / `record_milestones` / `ad_reward_coin` / `ad_daily_limit` / `ad_max_coin` / `ad_round_count` / `ad_cooldown_sec` / `signin_boost_coin` / `withdraw_auto_reconcile_enabled` / `comparing_ad_enabled` |
|
||||
| `value` | JSON(PG: JSONB) | NOT NULL | 配置值,类型随 key(`int` / `int_list` 如签到 14 档 / `dict_str_int` 如 task_rewards / `bool` 如 withdraw_auto_reconcile_enabled / comparing_ad_enabled) |
|
||||
| `updated_by_admin_id` | Integer | nullable | 最后修改的管理员 id(= `admin_user.id`,软引用,无 FK) |
|
||||
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 最后修改时间 |
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`;index `user_id`、`created_at`。
|
||||
- 部分唯一索引 `ux_cash_transaction_withdraw_refund_ref`(`ref_id`),条件 `biz_type = 'withdraw_refund' AND ref_id IS NOT NULL`:一笔提现单只能退款一次(按 `out_bill_no` 去重),挡重复退款。
|
||||
|
||||
## 注意
|
||||
- 退款流水 `remark` 是用户可见文案(区分"未成功自动退"vs"审核未通过退");技术原因记在 `withdraw_order.fail_reason`,不外露。
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
| 动作 / endpoint | `biz_type` | `amount` | `ref_id` 指向 |
|
||||
|---|---|---|---|
|
||||
| 签到 `POST /signin/do` | `signin` | + | 当天日期串(= `signin_record.signin_date` ISO) |
|
||||
| 签到后看广告膨胀 `POST /signin/boost` | `signin_boost` | + | 当天日期串(= `signin_boost_record.signin_date` ISO) |
|
||||
| 领任务 `POST /tasks/claim` | `task_<key>`(如 `task_enable_notification`) | + | `user_task.task_key` |
|
||||
| 签到后看广告膨胀 `POST /signin/boost` | `signin_boost` | + | 广告 `trans_id`(= `signin_boost_record.ad_ref_id`);无 ad_ref_id 时回退当天日期 ISO 串 |
|
||||
| 领任务 `POST /tasks/claim` | `task_<key>`(如 `task_enable_notification`) | + | 一次性任务=`user_task.task_key`;可重复任务(`enable_notification`)=带序号 `task_key:N` |
|
||||
| 普通激励视频 S2S 回调 `POST /ad/pangle-callback` | `reward_video`(历史兼容:`ad_reward`) | + | `ad_reward_record.trans_id` |
|
||||
| 信息流广告结算 `POST /ad/feed-reward` | `feed_ad_reward` | + | `ad_feed_reward_record.client_event_id` |
|
||||
| 金币兑现金 `POST /wallet/exchange` | `exchange_out` | − | null(配套 `cash_transaction.exchange_in`) |
|
||||
@@ -36,10 +36,11 @@
|
||||
|
||||
## 关系 / Join Key
|
||||
- `user_id` → `user.id`(多对一)。
|
||||
- `ref_id` 是**软关联**(无 FK),目标表随 `biz_type`:`signin`→签到日 / `signin_boost`→`ad_reward_record.trans_id` / `task_<key>`→`user_task.task_key` / `reward_video`/`ad_reward`→`ad_reward_record.trans_id` / `feed_ad_reward`→`ad_feed_reward_record.client_event_id` / 其余 null。
|
||||
- `ref_id` 是**软关联**(无 FK),目标随 `biz_type`:`signin`→签到日(`signin_record.signin_date` ISO) / `signin_boost`→`signin_boost_record.ad_ref_id`(无则当天日期) / `task_<key>`→一次性任务=`user_task.task_key`、可重复任务=`task_key:N` / `reward_video`/`ad_reward`→`ad_reward_record.trans_id` / `feed_ad_reward`→`ad_feed_reward_record.client_event_id` / 其余 null。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`;index `user_id`、`created_at`。
|
||||
- 部分唯一索引 `ux_coin_transaction_task_ref`(`user_id`, `biz_type`, `ref_id`),条件 `biz_type LIKE 'task%' AND ref_id IS NOT NULL`:可重复任务(如打开消息提醒)按 `ref_id` 序号(`task_key:N`)去重,挡并发/连点重复发放;仅 `task_*` 且 `ref_id` 非空生效,不影响 signin/exchange/withdraw 等其它 `biz_type`。
|
||||
|
||||
## 注意
|
||||
- 流水与余额快照(`coin_account`)、与对应业务记录(signin/task/ad_reward/feed_ad_reward 等)在**同一事务**写,不会只发币不留痕。
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
App「记录比价战绩」每档(第 1~6 次)只能领一次,领取后写一行去重。解锁进度**不存本表**——由 `comparison_record` 里 `status='success'` 的条数实时算(第 N 档在"成功比价 ≥ N 次"时解锁)。档位金额来自 `rewards.RECORD_MILESTONES`(默认 `(120,180,300,500,800,1200)`,运营后台 `app_config.record_milestones` 可改)。仿 `user_task` 的一次性领取模型。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C(插入)**:`POST /compare/milestone/claim`(`claim`)。档位合法 + 已解锁(`count_success >= milestone`)+ 未领过 → 写一行(`coin_awarded=0`)。越界 `UnknownMilestoneError`(404)、未解锁 `MilestoneLockedError`(409)、已领 `AlreadyClaimedError`(409)。
|
||||
- **C(插入)**:`POST /compare/milestones/{milestone}/claim`(`claim`)。档位合法 + 已解锁(`count_success >= milestone`)+ 未领过 → 写一行(`coin_awarded=0`)。越界 `UnknownMilestoneError`(404)、未解锁 `MilestoneLockedError`(409)、已领 `AlreadyClaimedError`(409)。
|
||||
- **U / D**:无。
|
||||
- **R**:`GET /compare/milestones`(战绩页:各档 claimed/active/locked + 成功次数),`get_status` 读本表已领集合 + `comparison_record` 成功计数对照。
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
每完成一次比价(外卖/电商/领券),客户端在 done 帧后用**带 JWT** 的通道上报一条。App「我的比价记录」列表/详情的数据源,也是比价战绩里程碑解锁进度的计数源(`status='success'` 条数),还被「上报更低价」反查原最低价。
|
||||
|
||||
> 与 `savings_record` 的区别:本表是「每一次**比价行为**的完整明细」(不省钱、甚至失败也记);`savings_record` 是「真正**下单成交**省了多少」。两表独立、互不喂数据。
|
||||
> 与 [`price_observation`](./price_observation.md) / `store_mapping` 的区别:本表是**用户视角**(登录后按 `user_id` 存「我的比价记录」);后两张是 server 侧无条件沉淀的**平台/门店视角客观事实**(价格事实 / 跨平台店铺身份映射),与本表 `trace_id` 同源但不互相 join,各存各的视角。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C / U(upsert,幂等)**:`POST /compare/record`(`upsert_record`)。按 `(user_id, trace_id)` 查:不存在→新建;已存在→整行覆盖(客户端重试/重复上报时,**更完整的那次胜出**)。`best_*`/`saved_amount_cents`/`is_source_best`/`status` 由 `_derive` 从 `comparison_results` 算出(协议已按 price 升序、rank=1 最便宜),不信客户端自算。
|
||||
@@ -19,6 +20,7 @@
|
||||
| `device_id` | String(64) | nullable | 设备号(多设备区分 / 与不鉴权期对账) |
|
||||
| `business_type` | String(16) | NOT NULL, default `food`, index | 取值:`food`(当前唯一接通)/ `ecom` / `coupon` |
|
||||
| `trace_id` | String(64) | NOT NULL | pricebot 侧 trace_id(关联调试落盘 + 幂等键) |
|
||||
| `trace_url` | String(512) | nullable | 本次比价公网调试链接(`price.shaguabijia.com/traces/{dir}/`);dir 名含 pricebot 落盘时分秒,前端/server 拼不出必须存。查看接口按 `user.debug_trace_enabled`(或本机 agent 调试 `include_trace`)决定返不返回。旧记录 / 未开上云为 null |
|
||||
| `source_platform_id` / `_name` | String(32) | nullable | 源平台代号 / 中文名 |
|
||||
| `source_package` | String(128) | nullable | 源平台 Android 包名 |
|
||||
| `source_price_cents` | Integer | nullable | 源平台到手价(分) |
|
||||
@@ -32,7 +34,7 @@
|
||||
| `status` | String(16) | NOT NULL, default `success` | 取值:`success`(有非源且有价的目标结果)/ `failed`(出错/没采到目标价)。**里程碑只数 success** |
|
||||
| `information` | String(256) | nullable | done 帧文案;成功=摘要,失败=具体原因(前端失败时当原因展示) |
|
||||
| `items` | JSON(PG: JSONB) | NOT NULL, default [] | 下单菜品 `[{name, qty, specs?}]` |
|
||||
| `comparison_results` | JSON(PG: JSONB) | NOT NULL, default [] | 逐平台对比 `[{platform_id,platform_name,package,price(元),is_source,rank,coupon_saved(元),coupon_name}]`;`coupon_saved`=该平台主优惠额(美团红包/淘宝平台红包/京东百亿补贴,只取一笔),`coupon_name`=优惠来源名(展示用) |
|
||||
| `comparison_results` | JSON(PG: JSONB) | NOT NULL, default [] | 逐平台对比 `[{platform_id,platform_name,package,price(元),is_source,rank,coupon_saved(元),coupon_name,applied_coupons}]`;`coupon_saved`=该平台主优惠额(美团红包/淘宝平台红包/京东百亿补贴,只取一笔),`coupon_name`=优惠来源名(展示用),`applied_coupons`=`[{name,amount}]` 多券明细 |
|
||||
| `skipped_dish_names` | JSON(PG: JSONB) | NOT NULL, default [] | 被跳过的菜名 |
|
||||
| `raw_payload` | JSON(PG: JSONB) | nullable | 客户端原始上报全量(calibration + done.params),取数兜底 |
|
||||
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
|
||||
@@ -46,7 +48,7 @@
|
||||
- 被 `comparison_milestone_claim` 间接依赖:解锁进度 = 本表 `status='success'` 计数。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`;index `user_id`、`business_type`、`created_at`;UNIQUE(`user_id`, `trace_id`) = `uq_comparison_user_trace`(幂等覆盖)。
|
||||
- PK `id`;index `user_id`、`business_type`、`created_at`;复合 index `ix_comparison_status_created`(`status`, `created_at`)(按 `status='success'` 过滤 + 近期排序的聚合/轮播,避免随数据量退化为全表扫);UNIQUE(`user_id`, `trace_id`) = `uq_comparison_user_trace`(幂等覆盖)。
|
||||
|
||||
## 注意
|
||||
- 4 个 JSON 列用 `JSON().with_variant(JSONB(),"postgresql")`(SQLite 退化 JSON)。结构化金额列存「分」,`comparison_results.price`/`coupon_saved` 原样存「元」。
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
领券(优惠券自动化)联动产生的三张「今日状态」表,都挂在领券透传端点 `POST /api/v1/coupon/step` 这条链路上(pricebot 跑领券,结果回 app-server 落库;**领券过程本身在 pricebot 内存态跑、不落库**)。三表各管一件事:
|
||||
|
||||
- **`coupon_prompt_engagement`** — 弹窗频控源。按 `(device, 自然日)` 记「今天是否对领券引导窗表达过**意向**」(点「一键领取」=`claim_started` / 点拒绝关闭=`dismissed` 都算)。切到外卖 App 时据此决定弹不弹:今天 engage 过就不再弹。
|
||||
- **`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 归因),当前**不参与**「要不要领 / 弹不弹」的判断。
|
||||
|
||||
@@ -19,35 +19,38 @@
|
||||
|
||||
---
|
||||
|
||||
## coupon_prompt_engagement — 弹窗频控(今日是否已对引导窗表达意向)
|
||||
## coupon_prompt_engagement — 弹窗频控(今日这个 App 是否已对引导窗表达意向)
|
||||
|
||||
`(device_id, engage_date)` 唯一,一台设备一天一条;今天 engage 过(领或拒)就不再弹。
|
||||
`(device_id, package, engage_date)` 唯一,一台设备、一个 App、一天一条;今天**这个 App** engage 过(弹/领/拒)就不再弹该 App。各 App 独立:美团弹过不压淘宝/京东。
|
||||
|
||||
### 用在哪 / 增删改查
|
||||
- **C / U(幂等 upsert)**:`mark_engagement`。两条触发:
|
||||
- **C / U(幂等 upsert)**:`mark_engagement`。三条触发:
|
||||
- `POST /api/v1/coupon/prompt/shown`(引导窗弹出那刻上报)→ 记 `shown`(频控主判据:弹出即占用今天这个 App 的「一次」,领/拒/无视都算用掉);
|
||||
- `POST /api/v1/coupon/step` 且 `step==0`(领券首帧=用户已发起领券)→ 记 `claim_started`;
|
||||
- `POST /api/v1/coupon/prompt/dismiss`(用户点关闭引导窗;server 在透传链路看不到「拒绝」,必须客户端通知)→ 记 `dismissed`。
|
||||
- 已有今天那条则覆盖 `engage_type`(并补 `user_id`),否则插入。
|
||||
- **D**:`POST /api/v1/coupon/prompt/reset`(`reset_today_engagement`)—— 删这台设备今天那条,开发设置「重置今日领券弹窗状态」按钮调,测频控用;删后今天又能弹。
|
||||
- **R**:`GET /api/v1/coupon/prompt/should-show?device_id=…`(`has_engaged_today`)→ `should_show = not 今天已 engage`。客户端切外卖 App 前查,纯后台判据。
|
||||
- 已有今天那条则覆盖 `engage_type`(并补 `user_id`),否则插入。`step` 帧的 `package` 取 step body 的 `package`,缺/空则退化为 `""` 占位(全局态)。
|
||||
- **D**:`POST /api/v1/coupon/prompt/reset`(`reset_today_engagement`)—— 删这台设备今天**所有 App** 的 engagement(不按 package 过滤:重置=从头测,清全部 App 最符预期),开发设置「重置今日领券弹窗状态」按钮调;删后今天又都能弹。
|
||||
- **R**:`GET /api/v1/coupon/prompt/should-show?device_id=…&package=…`(`has_engaged_today`)→ `should_show = not 今天该 App 已 engage`。客户端切外卖 App 前带 `package` 查(老客户端不带 → `""` 全局态)。
|
||||
|
||||
### 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `device_id` | String(64) | NOT NULL | 判断/聚合维度;客户端 `getOrCreateDeviceId`,重装会变 |
|
||||
| `package` | String(64) | NOT NULL, server_default `""` | 触发弹窗的目标 App 包名(`com.sankuai.meituan` / `com.taobao.taobao` / `com.jingdong.app.mall`)。频控维度,各 App 独立。旧行(2026-06-14 改造前)无此值 → 迁移用占位 `""` 填 |
|
||||
| `user_id` | Integer | index, 可空 | 登录态有就记(资产);不进唯一键、不阻塞判断 |
|
||||
| `engage_date` | **Date** | NOT NULL | **北京时间**自然日(`today_cn()`) |
|
||||
| `engage_type` | String(16) | NOT NULL | `claim_started`(点一键领取)/ `dismissed`(点拒绝关闭);仅记录区分,**判断只看「今天有没有这条」,type 不影响弹不弹** |
|
||||
| `engage_type` | String(16) | NOT NULL | `shown`(自动弹出即记)/ `claim_started`(点一键领取)/ `dismissed`(点拒绝关闭);仅记录区分,**判断只看「今天这个 App 有没有这条」,type 不影响弹不弹** |
|
||||
| `created_at` | DateTime(tz) | server_default now() | |
|
||||
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | |
|
||||
|
||||
### 索引与约束
|
||||
- PK `id`;index `user_id`;UNIQUE(`device_id`, `engage_date`) = `uq_coupon_engage_device_date`(一台设备一天一条)。
|
||||
- PK `id`;index `user_id`;UNIQUE(`device_id`, `package`, `engage_date`) = `uq_coupon_engage_device_pkg_date`(一台设备、一个 App、一天一条)。
|
||||
- 历史:原为 UNIQUE(`device_id`, `engage_date`) = `uq_coupon_engage_device_date`,2026-06-14 迁移 `coupon_engage_per_package` 加入 `package` 维度改成现状(修「任一 App 弹过就把整台设备当天标记、压住其余 App」的 bug)。
|
||||
|
||||
### 注意
|
||||
- `device_id` 重装会变 → 重装当新设备,今天重新弹一次(产品预期)。
|
||||
- 判断只看「今天这台设备有没有这条」,不看 `engage_type`(领或拒都算 engage 过、都不再弹)。
|
||||
- 判断只看「今天这台设备这个 App 有没有这条」,不看 `engage_type`(弹/领/拒都算 engage 过、都不再弹该 App)。
|
||||
|
||||
---
|
||||
|
||||
@@ -57,7 +60,7 @@
|
||||
|
||||
### 用在哪 / 增删改查
|
||||
- **C / U(幂等 upsert)**:`mark_completed_today`,由 `POST /api/v1/coupon/step` 在 pricebot 返回 `action.command == "done"` 那帧调。pricebot 把中途单券 done 改写成 `wait+continue=true`,只有整套全跑完那帧才保留 `command=="done"`,故 **done 已等价「整轮完成」**。已有今天那条则补 `user_id`/`trace_id`,否则插入。
|
||||
- **U / D**:无业务删除。
|
||||
- **D**:`POST /api/v1/coupon/completed-today/reset`(`reset_today_completion`)—— 删这台设备今天那条,首页「去领取」卡恢复可点。与 `/prompt/reset` 配套,开发设置「重置今日领券弹窗状态」一键把今日状态全清;MVP 不鉴权。
|
||||
- **R**:`GET /api/v1/coupon/completed-today?device_id=…`(`has_completed_today`)→ `completed`。客户端据此把首页「去领取」卡置灰、不可点。
|
||||
|
||||
### 字段
|
||||
@@ -119,5 +122,5 @@
|
||||
|
||||
## 三表共性小结
|
||||
- 数据流向:客户端 → `POST /api/v1/coupon/step`(透传给 pricebot)→ 结果回写这三张表(best-effort,写库失败不影响领券)。
|
||||
- 唯一键都含 `device_id` + 某个北京自然日列;`user_id` 永远是可空旁路(资产留痕,不进唯一键、不阻塞判断)。
|
||||
- 唯一键都含 `device_id` + 某个北京自然日列(engagement 还含 `package`,按 App 频控);`user_id` 永远是可空旁路(资产留痕,不进唯一键、不阻塞判断)。
|
||||
- 无硬外键:`user_id` 软指 `user.id`、`trace_id` 软指 pricebot work_logs(详见 [OVERVIEW → 表间关系 & Join Key](./OVERVIEW.md))。
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
> **相关文档**:
|
||||
> - 这份文档讲 **切引擎的完整步骤**(本机装 PG / 建库 / `scripts/init_postgres.py` 用法 / 生产切换)
|
||||
> - [数据库迁移.md](./数据库迁移.md) 讲 **alembic 操作**(如何建表/升级/新增迁移/多 head 排查/迁移链 12 条)
|
||||
> - [后端技术实现.md §10](./后端技术实现.md) 列待办与已知问题(含 `init_postgres.py` 已知小 bug)
|
||||
> - [后端技术实现.md §10](../后端技术实现.md) 列待办与已知问题(含 `init_postgres.py` 已知小 bug)
|
||||
>
|
||||
> **背景假设**:当前生产**无真实用户数据**(MVP 阶段),所以整个迁移本质是"切引擎",不涉及数据搬迁。如果将来已有真实数据,本文档不适用,需补充 `pgloader` 演练 + 钱表金额逐行核对 + 停服窗口 + 回滚预案。
|
||||
>
|
||||
@@ -248,7 +248,7 @@ sudo systemctl restart shaguabijia-app-server
|
||||
|
||||
## 6. 顺便建议:同时装上 Redis
|
||||
|
||||
PG 上线后,Redis 是下一个明确要补的基础设施(已在 [待办与技术债.md](./待办与技术债.md) 里列了用途)。**建议本次维护窗口顺手把 Redis 也装上**,不开始用、只装服务,免得下次还要单独申请部署:
|
||||
PG 上线后,Redis 是下一个明确要补的基础设施(已在 [待办与技术债.md](../guides/待办与技术债.md) 里列了用途)。**建议本次维护窗口顺手把 Redis 也装上**,不开始用、只装服务,免得下次还要单独申请部署:
|
||||
|
||||
```bash
|
||||
sudo apt install redis-server
|
||||
@@ -1,6 +1,6 @@
|
||||
# signin_boost_record — 签到膨胀记录
|
||||
|
||||
App 用户 Day1-Day13 当天签到后,看完激励视频可固定膨胀 2000 金币一次。Day14 不展示也不允许膨胀。本表记录膨胀动作,并用唯一约束防重复补发。
|
||||
App 用户当天签到后,看完激励视频可固定膨胀一次(默认 3000 金币,`rewards.SIGNIN_BOOST_COIN`,运营后台 `app_config.signin_boost_coin` 可改)。循环最后一天(`cycle_day == SIGNIN_CYCLE_LEN`,即 7 天循环的第 7 天)不展示也不允许膨胀。本表记录膨胀动作,并用唯一约束防重复补发。
|
||||
|
||||
## 字段
|
||||
|
||||
@@ -9,14 +9,14 @@ App 用户 Day1-Day13 当天签到后,看完激励视频可固定膨胀 2000
|
||||
| `id` | Integer | PK | 自增主键 |
|
||||
| `user_id` | Integer | FK → `user.id`, index, NOT NULL | 用户 |
|
||||
| `signin_date` | Date | NOT NULL | 被膨胀的签到日期,北京时间 |
|
||||
| `coin_awarded` | Integer | NOT NULL | 本次补发金币,默认固定 2000 |
|
||||
| `coin_awarded` | Integer | NOT NULL | 本次补发金币,默认固定 3000(`rewards.get_signin_boost_coin`) |
|
||||
| `ad_ref_id` | String(64) | nullable | 穿山甲 S2S 回调 `trans_id` |
|
||||
| `created_at` | DateTime | NOT NULL | 创建时间 |
|
||||
| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 创建时间 |
|
||||
|
||||
## 约束
|
||||
|
||||
- `UNIQUE(user_id, signin_date)`:同一用户同一天只能膨胀一次。
|
||||
- `UNIQUE(user_id, signin_date)` = `uq_signin_boost_user_date`:同一用户同一天只能膨胀一次。
|
||||
|
||||
## 关联
|
||||
|
||||
- 膨胀成功时写 `coin_transaction.biz_type=signin_boost`,`ref_id=ad_ref_id`。
|
||||
- 膨胀成功时写 `coin_transaction.biz_type=signin_boost`,`ref_id = ad_ref_id`(无 ad_ref_id 时回退当天日期 ISO 串)。
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# signin_record — 签到记录(14 天循环)
|
||||
# signin_record — 签到记录(7 天循环)
|
||||
|
||||
> 模型 `app/models/signin.py` · 仓库 `app/repositories/signin.py` · 接口 [signin-status](../api/signin-status.md) / [signin-do](../api/signin-do.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
App「每日签到」每签一次写一行,`(user_id, signin_date)` 唯一,天然防一天签两次。`cycle_day`(1..14)决定发多少金币(档位 `rewards.SIGNIN_REWARDS`,默认 `(200,120,150,180,200,250,500,200,250,300,350,400,500,3000)`,运营后台 `app_config.signin_rewards` 可改),断签重置回 1;第 15 天回到第 1 天;`streak` 是连续天数(不封顶),给"已连续签到 N 天"展示。
|
||||
App「每日签到」每签一次写一行,`(user_id, signin_date)` 唯一,天然防一天签两次。`cycle_day`(1..7)决定发多少金币(档位 `rewards.SIGNIN_REWARDS`,默认 `(200,200,300,200,400,400,800)`,一轮 7 天累计 2500 金币,运营后台 `app_config.signin_rewards` 可改),断签重置回 1;第 8 天回到第 1 天;`streak` 是连续天数(不封顶),给"已连续签到 N 天"展示。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C(插入)**:`POST /signin/do`(`do_signin`)。今天未签且校验通过 → 算出 `cycle_day`/`streak`(昨天签过则在上次基础上 +1、否则重置 1)→ 写一行 + `grant_coins(biz_type='signin', ref_id=今天日期)` 加币,**同事务 commit**。今天已签再调 → 抛 `AlreadySignedError`(409),不重复写。
|
||||
- **U / D**:无。每天一行,历史不改。
|
||||
- **R**:`GET /signin/status`(签到页:今天签没签、连续天数、14 档状态/金币、能否签),内部 `get_status` 读最近一条 + 推算今天档位。
|
||||
- **R**:`GET /signin/status`(签到页:今天签没签、连续天数、7 档状态/金币、能否签),内部 `get_status` 读最近一条 + 推算今天档位。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
@@ -15,7 +15,7 @@ App「每日签到」每签一次写一行,`(user_id, signin_date)` 唯一,天
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
|
||||
| `signin_date` | **Date** | NOT NULL | 签到日期(**北京时间** `cn_today()` 的 date)。其 ISO 串被 `coin_transaction.ref_id` 引用(biz_type=signin) |
|
||||
| `cycle_day` | Integer | NOT NULL | 取值 `1..14`:14 天循环里今天第几档,决定发币额;断签重置回 1 |
|
||||
| `cycle_day` | Integer | NOT NULL | 取值 `1..7`:7 天循环里今天第几档,决定发币额;断签重置回 1 |
|
||||
| `streak` | Integer | NOT NULL | 连续签到天数(≥1,不封顶);断签重置回 1 |
|
||||
| `coin_awarded` | Integer | NOT NULL | 本次实发金币(= 当时档位金额) |
|
||||
| `created_at` | DateTime(tz) | server_default now() | 时间 |
|
||||
|
||||
@@ -13,24 +13,27 @@
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | 用户主键;被 15 张表的 `user_id` 外键引用 |
|
||||
| `id` | Integer | PK, autoincrement | 用户主键;被多张业务表的 `user_id` 外键引用 |
|
||||
| `phone` | String(20) | UNIQUE, index, NOT NULL | 手机号(登录主键)。注销后置 `deleted_<id>` 占位释放唯一约束 |
|
||||
| `username` | String(11) | UNIQUE, index, NOT NULL | 对外展示账号 ID:11 位纯数字、首位非 1(与手机号天然区分)、全局唯一、创建时随机生成、不可变、**不参与登录**(登录仍走 phone)。生成见 `repositories/user._gen_username` |
|
||||
| `register_channel` | String(20) | NOT NULL, default `jverify` | 取值:`jverify`(极光一键)/ `sms`(短信);`savings_record` 的对比人群另有种子用户用 `seed` |
|
||||
| `nickname` | String(64) | nullable | 通用昵称(`PATCH /user/profile` 写) |
|
||||
| `avatar_url` | String(512) | nullable | 通用头像相对 URL `/media/avatars/...`(`POST /user/avatar` 写) |
|
||||
| `invite_code` | String(16) | UNIQUE, index, nullable | 邀请码:每用户一个稳定短码(懒生成,见 `repositories/invite.ensure_code`),分享链接/二维码里带它。UNIQUE 允许多个 NULL(未生成的用户) |
|
||||
| `wechat_openid` | String(64) | UNIQUE, index, nullable | 微信 openid(提现转账用);**一微信一账号**,多个 NULL 允许(多个未绑用户) |
|
||||
| `wechat_nickname` | String(64) | nullable | 微信昵称(绑定时拉,展示在提现绑定卡;与通用 nickname 分开存) |
|
||||
| `wechat_avatar_url` | String(512) | nullable | 微信头像 URL |
|
||||
| `status` | String(20) | NOT NULL, default `active` | 取值:`active`(可登录/鉴权)/ `disabled`(admin 禁用)/ `deleted`(已注销);仅 active 能通过鉴权 |
|
||||
| `debug_trace_enabled` | Boolean | NOT NULL, default false, server_default false | 调试链接权限:开了的用户在比价完成弹窗 + 比价记录页能看到「复制调试链接」按钮(运营后台按用户配置);`/me` 与登录响应带出给前端做条件渲染 |
|
||||
| `created_at` | DateTime(tz) | server_default now() | 注册时间 |
|
||||
| `last_login_at` | DateTime(tz) | 应用层 default utcnow | 最近登录时间(每次登录更新) |
|
||||
|
||||
## 关系 / Join Key
|
||||
- **被引用方(本表是 1,对方是 N/1)**:`coin_account`、`coin_transaction`、`cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`、`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` 的 `user_id` 均 → `user.id`。
|
||||
- **被引用方(本表是 1,对方是 N/1)**:`coin_account`、`coin_transaction`、`cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`、`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` 的 `user_id` 均 → `user.id`;`invite_relation` 的 `inviter_user_id` / `invitee_user_id` 均 → `user.id`。
|
||||
- 与 `admin_user` **无任何关联**(C 端用户 vs 后台管理员,两套体系)。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`;UNIQUE+index `phone`;UNIQUE+index `wechat_openid`(允许多 NULL)。
|
||||
- PK `id`;UNIQUE+index `phone`、`username`、`invite_code`(允许多 NULL)、`wechat_openid`(允许多 NULL)。
|
||||
|
||||
## 注意
|
||||
- `nickname/avatar_url`(通用)与 `wechat_nickname/wechat_avatar_url`(微信)**分开存,不互相覆盖**。
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
> 模型 `app/models/task.py` · 仓库 `app/repositories/task.py` · 接口 [tasks-list](../api/tasks-list.md) / [tasks-claim](../api/tasks-claim.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
像"打开消息提醒"这类**只能领一次**的任务,领奖后写一行,`(user_id, task_key)` 唯一防重复领。可循环领取的任务(签到)不走这张表,有专表 `signin_record`。任务字典与奖励来自 `rewards.TASK_REWARDS`(默认 `{enable_notification: 1000}`,运营后台 `app_config.task_rewards` 可改)。
|
||||
**只能领一次**的任务,领奖后写一行,`(user_id, task_key)` 唯一防重复领。可循环领取的任务(签到)不走这张表,有专表 `signin_record`。任务字典与奖励来自 `rewards.TASK_REWARDS`(默认 `{enable_notification: 750}`,运营后台 `app_config.task_rewards` 可改)。
|
||||
> ⚠️ 当前 `TASK_REWARDS` 里唯一的 `enable_notification`(打开消息提醒)已改为**可重复领取**任务(每次减半,见 `rewards.notification_reward`),它**不写本表**——领取次数靠数 `coin_transaction` 的正向流水条数(`biz_type='task_enable_notification'`),`ref_id` 带序号 `task_key:N`。因此本表目前实际**没有被任何已定义任务写入**;待新增真正的一次性任务后才会落行。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C(插入)**:`POST /tasks/claim`(`claim_task`)。`task_key` 合法 + 未领过 → 写一行(`status='completed'`)+ `grant_coins(biz_type='task_<key>', ref_id=task_key)` 加币,**同事务**。未知 key 抛 `UnknownTaskError`(404);已领抛 `AlreadyClaimedError`(409)。
|
||||
- **C(插入)**:`POST /tasks/claim`(`claim_task`),**仅一次性任务**走这条路:`task_key` 合法 + 未领过 → 写一行(`status='completed'`)+ `grant_coins(biz_type='task_<key>', ref_id=task_key)` 加币,**同事务**。未知 key 抛 `UnknownTaskError`(404);已领抛 `AlreadyClaimedError`(409)。可重复任务(`enable_notification`)在 `claim_task` 里走另一分支:**不写本表**,只发币(`ref_id=task_key:N`),减半到底(`notification_max_claims`)后再领抛 `AlreadyClaimedError`。
|
||||
- **U / D**:无。领过即终态。
|
||||
- **R**:`GET /tasks/list`(任务页:列出所有已知任务 + 是否已领),`list_tasks` 把本表已领的 `task_key` 集合与任务字典对照。
|
||||
|
||||
@@ -14,7 +15,7 @@
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
|
||||
| `task_key` | String(48) | NOT NULL | 任务标识,取值见 `rewards.TASK_REWARDS`(当前仅 `enable_notification`)。**被 `coin_transaction.ref_id` 引用**(biz_type=`task_<key>`) |
|
||||
| `task_key` | String(48) | NOT NULL | 任务标识,取值见 `rewards.TASK_REWARDS`(`enable_notification` 是可重复任务,不落本表)。一次性任务领取时其 `task_key` = 配套 `coin_transaction.ref_id`(biz_type=`task_<key>`) |
|
||||
| `status` | String(16) | NOT NULL, default `completed` | 当前恒为 `completed`(领即完成) |
|
||||
| `coin_awarded` | Integer | NOT NULL, default 0 | 该任务发放金币(= `TASK_REWARDS[task_key]`) |
|
||||
| `completed_at` | DateTime(tz) | server_default now() | 领取时间 |
|
||||
|
||||
@@ -44,6 +44,7 @@ reviewing ──admin 审核拒绝──▶ rejected(已退款)
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`;UNIQUE+index `out_bill_no`;index `user_id`、`created_at`。
|
||||
- 部分唯一索引 `ux_withdraw_order_user_active`(`user_id`),条件 `status IN ('reviewing', 'pending')`:每个用户同时只能有一笔在途(待审核 / 打款中)提现单,DB 层挡并发重复提现。
|
||||
|
||||
## 注意
|
||||
- **资金安全**:原子扣款(`WHERE cash_balance_cents >= amount`)+ `out_bill_no` 幂等 + 结果不明时**先查单再决定,绝不盲目退款**(防退款后又到账)+ 孤儿 pending 单 `reconcile_pending_withdraws` 对账兜底。
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
> **相关文档**:
|
||||
> - 这份文档讲 **alembic 操作**(如何建表/升级/新增迁移/多 head 排查)
|
||||
> - [postgres-migration.md](./postgres-migration.md) 讲 **从 SQLite 切到 PostgreSQL** 的完整步骤(本机安装/建库/`scripts/init_postgres.py` 用法)
|
||||
> - 配合 [后端技术实现.md §10](./后端技术实现.md) 看待办与已知问题
|
||||
> - 配合 [后端技术实现.md §10](../后端技术实现.md) 看待办与已知问题
|
||||
|
||||
---
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
## 技术债(暂不处理,知情即可)
|
||||
|
||||
- **agent 接口公网裸奔**:不鉴权 = 任何人可调,烧 pricebot 的 LLM 算力。与美团 3 接口现状同级,记账后随 P1 一起补。
|
||||
- **既有债(已记录在别处)**:美团 `referral-link` 的 `sid` 客户端可覆盖刷分佣;`logout` 占位不吊销 token;`SMS_MOCK=true` 全站免密登录;`JWT_SECRET_KEY` 默认值可伪造 token。详见 [后端技术实现.md](./后端技术实现.md) 与 [api/](./api/)。
|
||||
- **既有债(已记录在别处)**:美团 `referral-link` 的 `sid` 客户端可覆盖刷分佣;`logout` 占位不吊销 token;`SMS_MOCK=true` 全站免密登录;`JWT_SECRET_KEY` 默认值可伪造 token。详见 [后端技术实现.md](../后端技术实现.md) 与 [api/](./api/)。
|
||||
|
||||
---
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
|
||||
## 已解决
|
||||
|
||||
- ✅ **短信验证码接入极光(real 模式,2026-06-03)**:`/sms/send`+`/sms/login` 从 mock 升级为真实可用。`integrations/sms.py` 走**自定义验证码模式**(本服务 `secrets` 生成 6 位码 + 进程内存存储 + 极光 `/v1/messages` 只负责发 + 本地校验一次性/防爆破);鉴权**复用极光一键登录的 `JG_APP_KEY`/`JG_MASTER_SECRET`**(同一极光应用,**上线只需 `SMS_MOCK=false`**,无需额外凭证;`sign_id=31729`/`temp_id=1` 已审核就绪)。防刷四层(单号冷却 + 单号每日上限 + 单 IP `rate_limit` + 单码失败次数上限);`SmsError.status_code` 让发送失败按类型返回 429/503/400。**mock 保留"任意6位通过"**(不动其他 11 个测试文件的 `_login` helper);`tests/test_auth.py` 加 real 模式单测(极光协议 / 一次性 / 防爆破 / 余额回滚)。文档 [integrations/sms.md](./integrations/sms.md)。**遗留**:验证码进程内存,多 worker 需迁 DB/Redis(下方技术债)。
|
||||
- ✅ **短信验证码接入极光(real 模式,2026-06-03)**:`/sms/send`+`/sms/login` 从 mock 升级为真实可用。`integrations/sms.py` 走**自定义验证码模式**(本服务 `secrets` 生成 6 位码 + 进程内存存储 + 极光 `/v1/messages` 只负责发 + 本地校验一次性/防爆破);鉴权**复用极光一键登录的 `JG_APP_KEY`/`JG_MASTER_SECRET`**(同一极光应用,**上线只需 `SMS_MOCK=false`**,无需额外凭证;`sign_id=31729`/`temp_id=1` 已审核就绪)。防刷四层(单号冷却 + 单号每日上限 + 单 IP `rate_limit` + 单码失败次数上限);`SmsError.status_code` 让发送失败按类型返回 429/503/400。**mock 保留"任意6位通过"**(不动其他 11 个测试文件的 `_login` helper);`tests/test_auth.py` 加 real 模式单测(极光协议 / 一次性 / 防爆破 / 余额回滚)。文档 [integrations/sms.md](../integrations/sms.md)。**遗留**:验证码进程内存,多 worker 需迁 DB/Redis(下方技术债)。
|
||||
- ✅ **比价记录落库(server + client,2026-05-31)**:每次比价 done 后客户端上报、按 user 落库,作「我的比价记录」数据源 + 用户级画像沉淀。
|
||||
- **server**(本仓):新表 `comparison_record`(独立于 `savings_record`;结构化列 + `items`/`comparison_results`/`skipped_dish_names`/`raw_payload` 四个 JSON(B) 列)+ 3 个**鉴权**端点 `POST /api/v1/compare/record`(`(user_id,trace_id)` 幂等 upsert,best/saved/is_source_best/status 服务端从 comparison_results 派生)/ `GET /api/v1/compare/records`(游标分页)/ `GET /records/{id}`(含 raw_payload);`models/comparison.py` + `repositories/comparison.py` + `schemas/compare_record.py` + `api/v1/compare_record.py` + 迁移 `comparison_record_table`(head `b2c3d4e5f6a7`)+ `tests/test_compare_record.py`(8 例全过)+ `docs/api/compare-record-*.md`。
|
||||
- **client**(android 仓):`PriceBotService.runTask()` 比价 done 后(`lastDoneParams!=null`,成功/引擎失败都报)用独立 IO 协程尽力上报;`Protocol.CompareRecordRequest.fromComparison()` 从 calibration+done.params 零翻译组装;`ApiClient.reportCompareRecord()` 走新建的 `authedClient`(复用 `AuthInterceptor`+`RefreshAuthenticator`,自动 Bearer+401 刷新)。领券不报(非价格对比,本期范围只外卖)。
|
||||
@@ -99,7 +99,7 @@
|
||||
- ✅ **无障碍身份统一傻瓜比价**:服务名/图标继承 app;`accessibility_service_description`/前台通知/`GuideCopy`/引导 Activity 里的 "PriceBot" 全改「傻瓜比价」;app 图标换成完整 `logo1`(adaptive 居中+黄边,不裁切)。
|
||||
- ✅ **GuideOverlayService 组件名 bug**:批量 sed 改包名误把无障碍服务 ComponentName 的 packageName 改成 `…shaguabijia.agent`(应是 applicationId `…shaguabijia`)→ 无障碍开启检测永远 false;改用 `PermissionHelper.isAccessibilityEnabled` 动态构造。
|
||||
- ✅ **BuildConfig 包名**:`com.pricebot.app.BuildConfig` import 改本 app 包名。
|
||||
- ✅ **SQLite → PostgreSQL 切换**(2026-05-29,PR #6 + alembic 合并迁移):新增 `scripts/init_postgres.py` 一键脚本、`psycopg[binary]>=3.1` 依赖、`savings_record.dishes` 列改 JSONB、连接池参数(`pool_size=10/overflow=20/recycle=3600`)。SQLite 仍可作开发兜底(只改 `DATABASE_URL`)。详见 [postgres-migration.md](./postgres-migration.md)。
|
||||
- ✅ **SQLite → PostgreSQL 切换**(2026-05-29,PR #6 + alembic 合并迁移):新增 `scripts/init_postgres.py` 一键脚本、`psycopg[binary]>=3.1` 依赖、`savings_record.dishes` 列改 JSONB、连接池参数(`pool_size=10/overflow=20/recycle=3600`)。SQLite 仍可作开发兜底(只改 `DATABASE_URL`)。详见 [postgres-migration.md](../database/postgres-migration.md)。
|
||||
- ✅ **外卖比价透传 2 端点**(2026-05-27,`feat(compare)`):`/api/v1/intent/recognize` + `/api/v1/price/step` 在 `compare.py`,纯 body 透传到 pricebot-backend,`PRICEBOT_COMPARE_TIMEOUT_SEC=60` 独立超时。电商 2 个待接(`compare.py` 加两行即可)。
|
||||
- ✅ **用户资料 + 帮助反馈接口**(2026-05-28,PR #4):`PATCH /api/v1/user/profile` 改昵称、`POST /api/v1/user/avatar` 上传头像(魔数嗅探)、`DELETE /api/v1/user` 注销账号(软删+匿名化)、`POST /api/v1/feedback` 提交反馈(含截图);新建 `feedback` 表 + `core/media.py` + `/media` 静态服务。
|
||||
- ✅ **美团 3 端点未配凭证降级**(2026-05-28,`feat(meituan)`):未配 `MT_CPS_APP_KEY` 时 `coupons`/`feed`/`referral-link` 直接返空(不再 502),解决新开发机首屏炸的问题。**坏副作用**:`/feed` 跟"已配但调用失败"路径在响应上无法区分(都是 200 + 空 items),靠 `/health` 的 `mt_cps_configured` 字段区分。
|
||||
@@ -9,7 +9,7 @@
|
||||
客户端**不发奖**,`onRewardArrived` 只触发"去后端刷余额"。客户端被破解也刷不到钱。
|
||||
- 后端发奖**幂等**(按 `trans_id` 去重)+ **每日上限**(`DAILY_AD_REWARD_LIMIT`,按北京时间)。
|
||||
- 关键常量:`app/core/rewards.py` → `AD_REWARD_COIN=100`、`DAILY_AD_REWARD_LIMIT=500`、`VIDEO_ROUND_REQUIRED_COUNT=1`、`VIDEO_ROUND_COOLDOWN_SECONDS=3`(每次广告关闭后 3 秒短冷却)。旧的观看时长闸 `DAILY_AD_WATCH_SECONDS_LIMIT=0` 表示停用。
|
||||
- **4 态 CTA**(2026-05-29 PR #5):客户端任务行按钮在 Normal / Loading / Capped / CoolingDown 之间切,**全由后端 `reward-status` 的 `round_count` + `cooldown_until` 派生**(权威源),跨设备/重装/杀进程一致。详见 [api/ad-reward-status](./api/ad-reward-status.md)。
|
||||
- **4 态 CTA**(2026-05-29 PR #5):客户端任务行按钮在 Normal / Loading / Capped / CoolingDown 之间切,**全由后端 `reward-status` 的 `round_count` + `cooldown_until` 派生**(权威源),跨设备/重装/杀进程一致。详见 [api/ad-reward-status](../api/ad-reward-status.md)。
|
||||
|
||||
---
|
||||
|
||||
@@ -47,4 +47,4 @@ api 层 3 个端点都在入口处 `if not settings.mt_cps_configured:` 早返
|
||||
|
||||
## 备注
|
||||
- 美团接口当前**无鉴权**,且换链的 `sid` 允许客户端传值覆盖默认渠道——见对应 api 文档「备注」
|
||||
- 未配凭证降级、feed 静默吞错都在 [待办与技术债.md](../待办与技术债.md) 已记账
|
||||
- 未配凭证降级、feed 静默吞错都在 [待办与技术债.md](../guides/待办与技术债.md) 已记账
|
||||
|
||||
@@ -56,4 +56,4 @@
|
||||
3. 真机发一条验证:收到短信 + 能登录
|
||||
|
||||
## 已知局限
|
||||
**验证码存进程内存**:单 worker uvicorn 够用;重启丢码(用户重发即可);**多 worker / 多机不共享 → 冷却 / 每日上限 / 校验失效**,扩 worker 前迁移到 DB/Redis。见 [待办与技术债](../待办与技术债.md)。
|
||||
**验证码存进程内存**:单 worker uvicorn 够用;重启丢码(用户重发即可);**多 worker / 多机不共享 → 冷却 / 每日上限 / 校验失效**,扩 worker 前迁移到 DB/Redis。见 [待办与技术债](../guides/待办与技术债.md)。
|
||||
|
||||
+8
-8
@@ -36,7 +36,7 @@
|
||||
| ASGI | uvicorn[standard] | 生产 `--host 127.0.0.1 --port 8770` |
|
||||
| ORM | SQLAlchemy 2.0(Mapped 风格) | |
|
||||
| 迁移 | Alembic | |
|
||||
| DB | **PostgreSQL 16(生产)/ SQLite(开发兜底)** | 一键脚本 `scripts/init_postgres.py` 建库 + 写 `.env` + 跑迁移;开发环境改 `DATABASE_URL=sqlite:///./data/app.db` 即回退。无 Redis。详见 [docs/postgres-migration.md](./postgres-migration.md) |
|
||||
| DB | **PostgreSQL 16(生产)/ SQLite(开发兜底)** | 一键脚本 `scripts/init_postgres.py` 建库 + 写 `.env` + 跑迁移;开发环境改 `DATABASE_URL=sqlite:///./data/app.db` 即回退。无 Redis。详见 [docs/database/postgres-migration.md](./database/postgres-migration.md) |
|
||||
| DB 驱动 | psycopg3(`psycopg[binary]>=3.1`) | `DATABASE_URL=postgresql+psycopg://...`,**不要装 psycopg2**——SQLAlchemy 2.0 时代默认 psycopg3 |
|
||||
| Auth | PyJWT(HS256) | access + refresh |
|
||||
| 极光 | httpx + cryptography | REST 验 token + RSA 解密手机号 |
|
||||
@@ -125,8 +125,8 @@ tests/ # pytest(auth / health / welfare / withdraw / ad_re
|
||||
run.sh # 本地启动脚本(自动先跑迁移再起服务)
|
||||
docs/api/ # API 接口文档(索引 README + 一接口一文件)
|
||||
docs/integrations/ # 集成层实现文档(SDK 签名/加解密/协议细节)
|
||||
docs/数据库迁移.md # Alembic 迁移指南(如何建表/升级/新增迁移)
|
||||
docs/postgres-migration.md # SQLite → PostgreSQL 切换指南(配套 scripts/init_postgres.py)
|
||||
docs/database/数据库迁移.md # Alembic 迁移指南(如何建表/升级/新增迁移)
|
||||
docs/database/postgres-migration.md # SQLite → PostgreSQL 切换指南(配套 scripts/init_postgres.py)
|
||||
```
|
||||
|
||||
> **命名说明**:`api/v1/` 的 `v1` 用于 URL 版本化(移动端无法强制即时升级,需新旧版本并存能力);`integrations` 装外部 SDK 集成、`repositories` 装数据访问、`core` 装基础设施,三者分离。**数据访问层统一在 `repositories/`**(早期叫 `crud/`,2026-05 已整体并入,`crud/` 不再存在)。`coupon.py` 是领券透传,勿与 `meituan.py` 里的 `coupons`(券列表)混淆。
|
||||
@@ -255,7 +255,7 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
|
||||
|
||||
`upsert_user_for_login`:phone 存在则更新 `last_login_at`,不存在则注册(注册即登录)。
|
||||
|
||||
**Alembic 迁移**:`alembic/versions/` 当前 11 个迁移 + 1 个合并迁移(`f01db5d77dac_merge_pg_jsonb_and_feedback_heads.py`,2026-05-29 创建——实习生 PG 分支和 user-profile-feedback 分支并行开发时 down_revision 都挂在 `c8d9e0f1a2b3` 上,导致多 head,合并迁移把两条链合并)。详见 [数据库迁移.md](./数据库迁移.md)。
|
||||
**Alembic 迁移**:`alembic/versions/` 当前 11 个迁移 + 1 个合并迁移(`f01db5d77dac_merge_pg_jsonb_and_feedback_heads.py`,2026-05-29 创建——实习生 PG 分支和 user-profile-feedback 分支并行开发时 down_revision 都挂在 `c8d9e0f1a2b3` 上,导致多 head,合并迁移把两条链合并)。详见 [数据库迁移.md](./database/数据库迁移.md)。
|
||||
|
||||
---
|
||||
|
||||
@@ -277,7 +277,7 @@ scp secrets/jverify_rsa_private.pem server:/opt/shaguabijia-app-server/secrets/
|
||||
ssh server "cd /opt/shaguabijia-app-server && .venv/bin/alembic upgrade head && systemctl restart shaguabijia-app-server"
|
||||
```
|
||||
|
||||
完整 PG 切换流程见 [docs/postgres-migration.md](./postgres-migration.md)。
|
||||
完整 PG 切换流程见 [docs/database/postgres-migration.md](./database/postgres-migration.md)。
|
||||
|
||||
**生产 checklist(均为上线必查)**:
|
||||
|
||||
@@ -316,11 +316,11 @@ conda activate price # 首次:pip install -e .
|
||||
| 美团接口无鉴权 + 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](./待办与技术债.md) P1 |
|
||||
| agent 系列接口 MVP 不鉴权 | 拿不到 user_id → 无法采集"哪个用户领了/买了什么"用户级画像(商业模式核心资产)。见 [待办与技术债.md](./guides/待办与技术债.md) P1 |
|
||||
| SMS 已接极光(2026-06-03) | real 模式自定义验证码,上线只需 `SMS_MOCK=false`(复用极光凭证)。详见 [integrations/sms](./integrations/sms.md) |
|
||||
| 短信冷却存内存 | 扩 worker 前需迁移到 Redis |
|
||||
| `MEDIA_ROOT` 进程内 serve | 头像/反馈截图当前用 FastAPI StaticFiles,生产建议 nginx 直 serve 该目录 |
|
||||
| `init_postgres.py` 已知小 bug | 5 条小坑,见 [待办与技术债.md](./待办与技术债.md) |
|
||||
| `init_postgres.py` 已知小 bug | 5 条小坑,见 [待办与技术债.md](./guides/待办与技术债.md) |
|
||||
| Alembic 多 head 风险 | 跨分支并行开发要在 PR 合并前 `alembic heads` 检查只有一个;漏检会出现 "Multiple head revisions",需要 `alembic merge` 合并 |
|
||||
|
||||
> 完整接口协议见 [`docs/api/`](./api/)(38 个端点 + `/health` + `/media` 静态服务),以代码为准。
|
||||
> 完整接口协议见 [`docs/api/`](./api/)(70+ 接口,一接口一文件 + `/health` + `/media` 静态服务),以 `docs/api/README.md` 索引和代码为准。
|
||||
|
||||
@@ -315,6 +315,127 @@ def test_feed_reward_daily_display_cap(client, monkeypatch) -> None:
|
||||
assert _coin_balance(client, token) == before
|
||||
|
||||
|
||||
def test_feed_reward_segments_per_ad_ecpm(client) -> None:
|
||||
"""按条 segments 结算:每份用「满 10 秒那刻在播段」的 eCPM,跨条进位不浪费零头。"""
|
||||
phone = "13800003303"
|
||||
token = _login(client, phone)
|
||||
|
||||
# ad1: eCPM 200 看 25s;ad2: eCPM 400 看 7s。
|
||||
# 跨条进位:25s→2 份(@200,进位 5s);5+7=12s→第 3 份(@400,进位 2s 丢弃)。
|
||||
payload = {
|
||||
"client_event_id": "feed_seg_0001",
|
||||
"ad_session_id": "feedsess0001",
|
||||
"segments": [
|
||||
{"ecpm": "200", "seconds": 25},
|
||||
{"ecpm": "400", "seconds": 7},
|
||||
],
|
||||
}
|
||||
r = client.post("/api/v1/ad/feed-reward", json=payload, headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
expected = (
|
||||
calculate_ad_reward_coin("200", 1)
|
||||
+ calculate_ad_reward_coin("200", 2)
|
||||
+ calculate_ad_reward_coin("400", 3)
|
||||
)
|
||||
assert body["status"] == "granted"
|
||||
assert body["unit_count"] == 3
|
||||
assert body["coin"] == expected
|
||||
assert _coin_balance(client, token) == expected
|
||||
|
||||
|
||||
def test_feed_reward_session_unit_cap(client) -> None:
|
||||
"""单场份数封顶 12:超长 segments 也只发 12 份。"""
|
||||
phone = "13800003304"
|
||||
token = _login(client, phone)
|
||||
payload = {
|
||||
"client_event_id": "feed_cap_unit_1",
|
||||
"segments": [{"ecpm": "200", "seconds": 1000}], # 100 份 → 钳到 12
|
||||
}
|
||||
r = client.post("/api/v1/ad/feed-reward", json=payload, headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["unit_count"] == 12
|
||||
expected = sum(calculate_ad_reward_coin("200", i) for i in range(1, 13))
|
||||
assert r.json()["coin"] == expected
|
||||
|
||||
|
||||
def test_feed_reward_aborted_no_grant(client) -> None:
|
||||
"""中途关闭(aborted)即使看够时长也整场不发,记 closed_early。"""
|
||||
phone = "13800003305"
|
||||
token = _login(client, phone)
|
||||
payload = {
|
||||
"client_event_id": "feed_abort_1",
|
||||
"segments": [{"ecpm": "200", "seconds": 30}],
|
||||
"aborted": True,
|
||||
}
|
||||
r = client.post("/api/v1/ad/feed-reward", json=payload, headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["granted"] is False
|
||||
assert r.json()["status"] == "closed_early"
|
||||
assert r.json()["coin"] == 0
|
||||
assert _coin_balance(client, token) == 0
|
||||
|
||||
|
||||
def test_feed_reward_requires_segments_or_ecpm(client) -> None:
|
||||
"""既不传 segments 也不传 ecpm → 422。"""
|
||||
phone = "13800003306"
|
||||
token = _login(client, phone)
|
||||
r = client.post(
|
||||
"/api/v1/ad/feed-reward",
|
||||
json={"client_event_id": "feed_bad_1", "duration_seconds": 30},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 422, r.text
|
||||
|
||||
|
||||
def test_feed_preview_matches_grant(client) -> None:
|
||||
"""preview 与最终 feed-reward 同口径:preview 的 coin == 同 segments 实发 coin。"""
|
||||
phone = "13800003307"
|
||||
token = _login(client, phone)
|
||||
segments = [{"ecpm": "200", "seconds": 25}, {"ecpm": "400", "seconds": 7}]
|
||||
|
||||
pv = client.post(
|
||||
"/api/v1/ad/feed-reward/preview", json={"segments": segments}, headers=_auth(token)
|
||||
)
|
||||
assert pv.status_code == 200, pv.text
|
||||
pvb = pv.json()
|
||||
assert pvb["unit_count"] == 3
|
||||
assert pvb["would_status"] == "granted"
|
||||
assert pvb["session_full"] is False
|
||||
# 下一份(第 4 份)边际金币,按末段 eCPM=400 算
|
||||
assert pvb["next_unit_coin"] == calculate_ad_reward_coin("400", 4)
|
||||
|
||||
# preview 不入账:此时余额仍为 0
|
||||
assert _coin_balance(client, token) == 0
|
||||
|
||||
# 真正结算,coin 应与 preview 一致
|
||||
gr = client.post(
|
||||
"/api/v1/ad/feed-reward",
|
||||
json={"client_event_id": "feed_pv_1", "segments": segments},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert gr.status_code == 200, gr.text
|
||||
assert gr.json()["coin"] == pvb["coin"]
|
||||
assert _coin_balance(client, token) == pvb["coin"]
|
||||
|
||||
|
||||
def test_feed_preview_too_short(client) -> None:
|
||||
"""不足 10 秒:preview would_status=too_short、coin=0、next_unit_coin>0(本份在攒)。"""
|
||||
phone = "13800003308"
|
||||
token = _login(client, phone)
|
||||
pv = client.post(
|
||||
"/api/v1/ad/feed-reward/preview",
|
||||
json={"segments": [{"ecpm": "200", "seconds": 6}]},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert pv.status_code == 200, pv.text
|
||||
body = pv.json()
|
||||
assert body["unit_count"] == 0
|
||||
assert body["coin"] == 0
|
||||
assert body["would_status"] == "too_short"
|
||||
assert body["next_unit_coin"] == calculate_ad_reward_coin("200", 1)
|
||||
|
||||
|
||||
def test_callback_disabled_returns_503(client, monkeypatch) -> None:
|
||||
"""未配置回调(开关关)时 → 503。"""
|
||||
monkeypatch.setattr(settings, "PANGLE_CALLBACK_ENABLED", False)
|
||||
|
||||
Reference in New Issue
Block a user