Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 733a51260f | |||
| 7fd70a264d | |||
| 4b98d405d7 | |||
| 63400c29cc | |||
| 42be0f828f | |||
| 5bafe5b71a | |||
| 498da05995 | |||
| 3cab3b9055 | |||
| f790519765 |
@@ -28,48 +28,34 @@ 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\"]'")
|
||||
op.add_column(
|
||||
"cps_group",
|
||||
sa.Column(
|
||||
"platforms",
|
||||
sa.JSON().with_variant(postgresql.JSONB(), "postgresql"),
|
||||
nullable=False,
|
||||
server_default=sa.text("'[\"meituan\"]'::jsonb"),
|
||||
),
|
||||
)
|
||||
# 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)
|
||||
op.alter_column("cps_group", "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))
|
||||
op.alter_column("cps_link", "sid", existing_type=sa.String(64), nullable=True)
|
||||
op.alter_column("cps_link", "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")
|
||||
)
|
||||
op.alter_column("cps_click", "sid", existing_type=sa.String(64), nullable=True)
|
||||
op.add_column(
|
||||
"cps_click",
|
||||
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_click", "event_type")
|
||||
op.alter_column("cps_click", "sid", existing_type=sa.String(64), nullable=False)
|
||||
op.alter_column("cps_link", "target_url", existing_type=sa.String(2048), type_=sa.String(1024))
|
||||
op.alter_column("cps_link", "sid", existing_type=sa.String(64), nullable=False)
|
||||
op.drop_column("cps_group", "platforms")
|
||||
op.alter_column("cps_group", "sid", existing_type=sa.String(64), nullable=False)
|
||||
op.drop_column("cps_activity", "payload")
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""cps 微信用户表 cps_wx_user + cps_click 加 openid
|
||||
|
||||
CPS 落地页微信网页授权拿 openid/昵称头像,做用户级群统计。
|
||||
|
||||
Revision ID: cps_wx_user
|
||||
Revises: comparison_debug_fields
|
||||
Create Date: 2026-06-19 12:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "cps_wx_user"
|
||||
down_revision: Union[str, Sequence[str], None] = "comparison_debug_fields"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"cps_wx_user",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("openid", sa.String(64), nullable=False),
|
||||
sa.Column("unionid", sa.String(64), nullable=True),
|
||||
sa.Column("nickname", sa.String(128), nullable=True),
|
||||
sa.Column("headimgurl", sa.String(512), nullable=True),
|
||||
sa.Column("first_code", sa.String(16), nullable=True),
|
||||
sa.Column("first_group_id", sa.Integer(), nullable=True),
|
||||
sa.Column("first_seen", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("last_seen", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_cps_wx_user_openid", "cps_wx_user", ["openid"], unique=True)
|
||||
op.create_index("ix_cps_wx_user_unionid", "cps_wx_user", ["unionid"])
|
||||
op.create_index("ix_cps_wx_user_first_group_id", "cps_wx_user", ["first_group_id"])
|
||||
|
||||
op.add_column("cps_click", sa.Column("openid", sa.String(64), nullable=True))
|
||||
op.create_index("ix_cps_click_openid", "cps_click", ["openid"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_cps_click_openid", table_name="cps_click")
|
||||
op.drop_column("cps_click", "openid")
|
||||
op.drop_index("ix_cps_wx_user_first_group_id", table_name="cps_wx_user")
|
||||
op.drop_index("ix_cps_wx_user_unionid", table_name="cps_wx_user")
|
||||
op.drop_index("ix_cps_wx_user_openid", table_name="cps_wx_user")
|
||||
op.drop_table("cps_wx_user")
|
||||
@@ -9,7 +9,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import desc, select
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.admin.repositories.queries import _as_utc, offset_paginate
|
||||
@@ -19,6 +19,7 @@ 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
|
||||
from app.models.cps_wx_user import CpsWxUser
|
||||
|
||||
# 美团订单状态:取消(4)/风控(5)不计佣金;结算(6)为佣金真正到账
|
||||
_INVALID_STATUS = {"4", "5"}
|
||||
@@ -394,7 +395,10 @@ def group_click_timeseries(
|
||||
|
||||
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)
|
||||
# naive(本地 SQLite 读回无 tz)按 UTC 兜底,再转北京——与 queries._as_utc 约定一致,
|
||||
# 否则 astimezone 会把 UTC 瞬刻当系统本地时区解释,分桶整体偏移。
|
||||
ca = clicked_at if clicked_at.tzinfo else clicked_at.replace(tzinfo=timezone.utc)
|
||||
bj = ca.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 ''}"
|
||||
@@ -425,3 +429,89 @@ def group_click_timeseries(
|
||||
for h in range(24):
|
||||
points.append(_point(f"{h:02d}:00", agg.get(h)))
|
||||
return points
|
||||
|
||||
|
||||
def group_order_daily(
|
||||
db: Session, *, sid: str, start: datetime, end: datetime,
|
||||
) -> dict[str, dict]:
|
||||
"""美团群订单按天(北京时区,key=YYYY-MM-DD)聚合。口径与 group_stats._order_agg 一致:
|
||||
取消(4)/风控(5)不计有效;结算只数 status=6。退款佣金取当天全部单的 refund_profit_cents 之和。
|
||||
返回 {date: {order_count, canceled_count, gmv_cents, est_commission_cents,
|
||||
refund_profit_cents, settled_commission_cents}};无单的天不在 dict 里(端点侧补零)。
|
||||
"""
|
||||
rows = (
|
||||
db.execute(
|
||||
select(CpsOrder)
|
||||
.where(CpsOrder.sid == sid)
|
||||
.where(CpsOrder.pay_time >= _as_utc(start))
|
||||
.where(CpsOrder.pay_time <= _as_utc(end))
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
by_day: dict[str, list] = {}
|
||||
for o in rows:
|
||||
if not o.pay_time:
|
||||
continue
|
||||
pt = o.pay_time if o.pay_time.tzinfo else o.pay_time.replace(tzinfo=timezone.utc)
|
||||
d = pt.astimezone(_BJ_TZ).strftime("%Y-%m-%d")
|
||||
by_day.setdefault(d, []).append(o)
|
||||
|
||||
out: dict[str, dict] = {}
|
||||
for d, items in by_day.items():
|
||||
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]
|
||||
out[d] = {
|
||||
"order_count": len(valid),
|
||||
"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),
|
||||
"refund_profit_cents": sum(o.refund_profit_cents or 0 for o in items),
|
||||
"settled_commission_cents": sum(o.commission_cents or 0 for o in settled),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def group_wx_users(db: Session, *, group_id: int, limit: int = 200) -> list[dict]:
|
||||
"""群内微信用户(首次来源 first_group_id=group_id)+ 每人在该群的 visit/copy 次数(领券画像)。
|
||||
|
||||
按首次进入倒序。copy 次数 = 该用户在该群点了几次「复制口令」(领券意向),visit = 进落地页次数。
|
||||
"""
|
||||
users = (
|
||||
db.execute(
|
||||
select(CpsWxUser)
|
||||
.where(CpsWxUser.first_group_id == group_id)
|
||||
.order_by(desc(CpsWxUser.first_seen))
|
||||
.limit(limit)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
if not users:
|
||||
return []
|
||||
openids = [u.openid for u in users]
|
||||
stat: dict[str, dict] = {}
|
||||
rows = db.execute(
|
||||
select(CpsClick.openid, CpsClick.event_type, func.count())
|
||||
.where(CpsClick.group_id == group_id)
|
||||
.where(CpsClick.openid.in_(openids))
|
||||
.group_by(CpsClick.openid, CpsClick.event_type)
|
||||
).all()
|
||||
for openid, event_type, cnt in rows:
|
||||
s = stat.setdefault(openid, {"visit": 0, "copy": 0})
|
||||
if event_type == "copy":
|
||||
s["copy"] = cnt
|
||||
else:
|
||||
s["visit"] = cnt
|
||||
return [
|
||||
{
|
||||
"openid": u.openid,
|
||||
"nickname": u.nickname,
|
||||
"headimgurl": u.headimgurl,
|
||||
"first_seen": u.first_seen,
|
||||
"visit_count": stat.get(u.openid, {}).get("visit", 0),
|
||||
"copy_count": stat.get(u.openid, {}).get("copy", 0),
|
||||
}
|
||||
for u in users
|
||||
]
|
||||
|
||||
@@ -30,7 +30,7 @@ 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,
|
||||
status: Annotated[str | None, Query(pattern="^(success|failed|cancelled)$")] = None,
|
||||
business_type: Annotated[str | None, Query()] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
|
||||
@@ -433,3 +433,66 @@ def group_timeseries(
|
||||
"granularity": granularity,
|
||||
"points": points,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}/daily", summary="群每天明细大表格(点击+订单按天合并)")
|
||||
def group_daily(
|
||||
group_id: int,
|
||||
db: AdminDb,
|
||||
days: Annotated[int, Query(ge=1, le=90)] = 7,
|
||||
) -> 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)
|
||||
start = (now_bj - timedelta(days=days - 1)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
end = now_bj
|
||||
# 点击侧:连续 N 天补零,顺序 = start..end(与下面 while 同序,按 index 对齐避免跨年 MM-DD 碰撞)
|
||||
click_points = cps_repo.group_click_timeseries(
|
||||
db, group_id=group_id, granularity="day", start=start, end=end
|
||||
)
|
||||
# 订单侧:仅美团群(有 sid)有对账;淘宝/京东订单列返回 None(前端显示 "-")
|
||||
is_meituan = "meituan" in (group.platforms or []) and bool(group.sid)
|
||||
order_daily = (
|
||||
cps_repo.group_order_daily(db, sid=group.sid, start=start, end=end) if is_meituan else {}
|
||||
)
|
||||
_ORDER_KEYS = (
|
||||
"order_count", "canceled_count", "gmv_cents",
|
||||
"est_commission_cents", "refund_profit_cents", "settled_commission_cents",
|
||||
)
|
||||
rows = []
|
||||
cur = start.astimezone(bj).date()
|
||||
last = end.astimezone(bj).date()
|
||||
idx = 0
|
||||
while cur <= last:
|
||||
cp = click_points[idx] if idx < len(click_points) else None
|
||||
row = {
|
||||
"date": cur.strftime("%m-%d"),
|
||||
"click_pv": cp["click_pv"] if cp else 0,
|
||||
"click_uv": cp["click_uv"] if cp else 0,
|
||||
"copy_pv": cp["copy_pv"] if cp else 0,
|
||||
}
|
||||
if is_meituan:
|
||||
od = order_daily.get(cur.strftime("%Y-%m-%d"))
|
||||
row.update({k: (od[k] if od else 0) for k in _ORDER_KEYS})
|
||||
else:
|
||||
row.update({k: None for k in _ORDER_KEYS})
|
||||
rows.append(row)
|
||||
idx += 1
|
||||
cur += timedelta(days=1)
|
||||
return {
|
||||
"group_id": group.id,
|
||||
"group_name": group.name,
|
||||
"is_meituan": is_meituan,
|
||||
"days": days,
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}/wx-users", summary="群内微信用户(领券画像:头像/昵称/领券次数)")
|
||||
def group_wx_users(group_id: int, db: AdminDb) -> dict:
|
||||
group = cps_repo.get_group(db, group_id)
|
||||
if group is None:
|
||||
raise HTTPException(status_code=404, detail="群不存在")
|
||||
return {"users": cps_repo.group_wx_users(db, group_id=group_id)}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""最新 App 版本写入端点(发布流程 → app-server)。
|
||||
|
||||
发车流程出 APK 后,把版本号 / 下载链接 / sha256 等 POST 到这里,落 app_config(key=latest_app_version)。
|
||||
客户端再 GET /api/v1/platform/app-version 读取做 OTA 检查更新。**不是给客户端的接口**:
|
||||
不走用户 JWT,靠 server 间共享密钥头 `X-Internal-Secret` 校验(== settings.INTERNAL_API_SECRET)。
|
||||
密钥未配置(默认空)时直接 503,避免裸奔的写端点。也是应急改版本信息(紧急下线/改 apk_url)的入口。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, status
|
||||
|
||||
from app.api.deps import DbSession
|
||||
from app.core.config import settings
|
||||
from app.repositories import app_config
|
||||
from app.schemas.platform import AppVersionOut, AppVersionWriteIn
|
||||
|
||||
logger = logging.getLogger("shagua.internal.app_version")
|
||||
|
||||
router = APIRouter(prefix="/internal", tags=["internal"])
|
||||
|
||||
|
||||
def _check_secret(x_internal_secret: str | None) -> None:
|
||||
"""共享密钥校验。未配置 → 503(挡住裸奔写端点);不匹配 → 401(常量时间比较)。"""
|
||||
configured = settings.INTERNAL_API_SECRET
|
||||
if not configured:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="internal api not configured",
|
||||
)
|
||||
if not x_internal_secret or not hmac.compare_digest(x_internal_secret, configured):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="invalid internal secret",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/app-version",
|
||||
response_model=AppVersionOut,
|
||||
summary="写最新 App 版本(发布流程→app-server,落 app_config)",
|
||||
)
|
||||
def write_app_version(
|
||||
payload: AppVersionWriteIn,
|
||||
db: DbSession,
|
||||
x_internal_secret: Annotated[str | None, Header()] = None,
|
||||
) -> AppVersionOut:
|
||||
_check_secret(x_internal_secret)
|
||||
data = payload.model_dump()
|
||||
app_config.set_app_version(db, data)
|
||||
logger.info(
|
||||
"app_version set code=%d name=%s url=%s",
|
||||
payload.latest_version_code, payload.latest_version_name, payload.apk_url,
|
||||
)
|
||||
return AppVersionOut(**data)
|
||||
+4
-42
@@ -29,8 +29,6 @@ from app.schemas.ad import (
|
||||
AdRewardStatusOut,
|
||||
EcpmReportIn,
|
||||
EcpmReportOut,
|
||||
FeedPreviewIn,
|
||||
FeedPreviewOut,
|
||||
FeedRewardIn,
|
||||
FeedRewardOut,
|
||||
PangleCallbackOut,
|
||||
@@ -345,23 +343,6 @@ 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,
|
||||
@@ -369,18 +350,16 @@ def _feed_segments(payload: FeedRewardIn) -> tuple[list[tuple[str, int]], str]:
|
||||
dependencies=[Depends(rate_limit(120, 60, "ad-feed-reward"))],
|
||||
)
|
||||
def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> FeedRewardOut:
|
||||
"""点位 2:信息流广告每展示满 10 秒累计一份奖励,整场结束(全程不关)一次性入账。
|
||||
"""点位 2:信息流广告每展示满 10 秒累计一份奖励,视频完成后一次性入账。
|
||||
|
||||
新客户端按条上报 [segments](每条 eCPM + 秒数),服务端按时序跨条进位精确结算;旧客户端只传
|
||||
ecpm + duration_seconds 时回退当单段。client_event_id 做幂等键,避免重试重复发。
|
||||
当前一期由客户端完成回调携带 eCPM / 展示秒数上报;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,
|
||||
segments=segments,
|
||||
ecpm_repr=ecpm_repr,
|
||||
ecpm=payload.ecpm,
|
||||
duration_seconds=payload.duration_seconds,
|
||||
ad_session_id=payload.ad_session_id,
|
||||
adn=payload.adn,
|
||||
slot_id=payload.slot_id,
|
||||
@@ -401,23 +380,6 @@ 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,
|
||||
|
||||
@@ -15,9 +15,11 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, status
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.repositories import comparison as crud_compare
|
||||
from app.services.pricebot_llm_calls import fetch_llm_calls
|
||||
from app.schemas.compare_record import (
|
||||
@@ -40,29 +42,49 @@ router = APIRouter(prefix="/api/v1/compare", tags=["compare-record"])
|
||||
summary="上报一次比价结果(幂等)",
|
||||
)
|
||||
def report_record(
|
||||
payload: ComparisonRecordIn, user: CurrentUser, db: DbSession
|
||||
payload: ComparisonRecordIn,
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
background_tasks: BackgroundTasks,
|
||||
) -> 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()
|
||||
# LLM 调用明细异步回填:同机拉 pricebot llm_calls 最长 5s 且是 best-effort,放后台
|
||||
# 任务做,不阻塞上报响应(顺带给 pricebot 落盘留足余量)。upsert 已 commit,后台用
|
||||
# 独立 session 按 record id 回填 llm_calls + 派生 llm_call_count/retry_count。
|
||||
background_tasks.add_task(_backfill_llm_calls, rec.id, rec.trace_id)
|
||||
logger.info(
|
||||
"compare record user=%s trace=%s biz=%s status=%s saved=%s llm_calls=%d",
|
||||
"compare record user=%s trace=%s biz=%s status=%s saved=%s (llm_calls backfill queued)",
|
||||
user.id,
|
||||
rec.trace_id,
|
||||
rec.business_type,
|
||||
rec.status,
|
||||
rec.saved_amount_cents,
|
||||
len(calls),
|
||||
)
|
||||
return ComparisonRecordCreatedOut(id=rec.id)
|
||||
|
||||
|
||||
def _backfill_llm_calls(record_id: int, trace_id: str) -> None:
|
||||
"""后台回填本次比价的 LLM 调用明细 + 派生 llm_call_count/retry_count。
|
||||
独立 DB session(请求 session 此时已关);拉取/写库失败只 log,绝不影响已落库的上报。"""
|
||||
calls = fetch_llm_calls(trace_id)
|
||||
if not calls:
|
||||
return
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rec = db.get(ComparisonRecord, record_id)
|
||||
if rec is None:
|
||||
return
|
||||
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("backfill llm_calls trace=%s n=%d", trace_id, len(calls))
|
||||
except Exception as e: # noqa: BLE001 best-effort
|
||||
logger.warning("backfill llm_calls failed trace=%s: %s", trace_id, e)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/stats",
|
||||
response_model=CompareStatsOut,
|
||||
|
||||
+98
-12
@@ -8,16 +8,22 @@ from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import media
|
||||
from app.core.config import settings
|
||||
from app.db.session import get_db
|
||||
from app.integrations import wx_oauth
|
||||
from app.models.cps_activity import CpsActivity
|
||||
from app.models.cps_link import CpsLink
|
||||
from app.repositories import cps_link as cps_link_repo
|
||||
from app.repositories import cps_wx_user as cps_wx_user_repo
|
||||
|
||||
logger = logging.getLogger("shagua.cps_redirect")
|
||||
|
||||
router = APIRouter(tags=["cps-redirect"])
|
||||
|
||||
@@ -27,6 +33,16 @@ _FALLBACK_URL = "https://www.meituan.com/"
|
||||
# 淘宝活动未设图时的兜底主视觉(存量已回填,基本只在老 link/异常时触发)
|
||||
_DEFAULT_TAOBAO_IMAGE = "/media/taobao_landing.jpg"
|
||||
|
||||
# 微信网页授权拿到的用户标识 cookie(种在 coupon 域,30 天免重复授权)
|
||||
_WX_OPENID_COOKIE = "wx_openid"
|
||||
_WX_UINFO_COOKIE = "wx_uinfo" # "1" = 已拿过昵称头像(userinfo),点领券不再跳授权
|
||||
_COOKIE_MAX_AGE = 30 * 86400
|
||||
|
||||
|
||||
def _is_wechat(request: Request) -> bool:
|
||||
"""是否微信内置浏览器(网页授权只在微信内有意义,外部浏览器不跳授权)。"""
|
||||
return "micromessenger" in (request.headers.get("user-agent", "").lower())
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str | None:
|
||||
xff = request.headers.get("x-forwarded-for")
|
||||
@@ -35,48 +51,109 @@ def _client_ip(request: Request) -> str | None:
|
||||
return request.client.host if request.client else None
|
||||
|
||||
|
||||
def _record(db: Session, link: CpsLink, request: Request, event_type: str) -> None:
|
||||
def _record(
|
||||
db: Session, link: CpsLink, request: Request, event_type: str, openid: str | None = None
|
||||
) -> None:
|
||||
try:
|
||||
cps_link_repo.record_click(
|
||||
db, link=link, ip=_client_ip(request),
|
||||
ua=request.headers.get("user-agent"), event_type=event_type,
|
||||
ua=request.headers.get("user-agent"), event_type=event_type, openid=openid,
|
||||
)
|
||||
except Exception:
|
||||
pass # 记点击失败不阻断
|
||||
|
||||
|
||||
@router.get("/c/{code}", summary="群发短链落地(记点击 + 跳转/淘宝落地页)")
|
||||
@router.get("/MP_verify_F7wnRQ7xPbhVOWC8.txt", include_in_schema=False)
|
||||
def wx_mp_domain_verify() -> PlainTextResponse:
|
||||
"""微信「网页授权域名」归属校验文件。coupon.shaguabijia.com 全反代 app-server,
|
||||
微信请求 coupon.shaguabijia.com/MP_verify_xxx.txt 验证域名归属;内容=文件名核心串。
|
||||
放域名根目录(本端点即根路径),为微信网页授权(拿 openid)接入做准备。"""
|
||||
return PlainTextResponse("F7wnRQ7xPbhVOWC8")
|
||||
|
||||
|
||||
@router.get("/c/{code}", summary="群发短链落地(微信授权拿 openid + 记点击 + 跳转/淘宝落地页)")
|
||||
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")
|
||||
openid = request.cookies.get(_WX_OPENID_COOKIE)
|
||||
# 微信内 + 还没拿到 openid + 配了服务号 → 先 base 静默授权拿 openid,再 302 回本页。
|
||||
# 非微信 / 已有 cookie / 未配服务号 → 直接走原逻辑(openid 可能为 None,绝不阻断领券)。
|
||||
if openid is None and settings.wx_oauth_active and _is_wechat(request):
|
||||
redirect_uri = f"{settings.CPS_REDIRECT_BASE.rstrip('/')}/wx/oauth/cb"
|
||||
auth_url = wx_oauth.build_authorize_url(redirect_uri, "snsapi_base", f"base:{code}")
|
||||
return RedirectResponse(auth_url, status_code=302)
|
||||
_record(db, link, request, "visit", openid=openid)
|
||||
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))
|
||||
has_uinfo = request.cookies.get(_WX_UINFO_COOKIE) == "1"
|
||||
return HTMLResponse(
|
||||
_taobao_landing_html(link.target_url, image_url, code, openid, has_uinfo)
|
||||
)
|
||||
# 美团短链 / 京东链接:直接 302 跳
|
||||
return RedirectResponse(link.target_url, status_code=302)
|
||||
|
||||
|
||||
@router.post("/c/{code}/copy", summary="淘宝落地页点了「复制口令」(记 copy 事件)")
|
||||
@router.get("/wx/oauth/cb", include_in_schema=False)
|
||||
def wx_oauth_cb(code: str, state: str, db: Session = Depends(get_db)):
|
||||
"""微信网页授权回调。state='base:{原code}'(静默拿 openid) 或 'uinfo:{原code}'(补昵称头像)。
|
||||
换 openid(+userinfo)→ upsert 用户 → 种 cookie → 302 回落地页。任何失败兜底回落地页,
|
||||
绝不阻断用户领券。"""
|
||||
kind, _, orig_code = state.partition(":")
|
||||
try:
|
||||
token = wx_oauth.exchange_code(code) # {openid, access_token, scope, ...}
|
||||
openid = token["openid"]
|
||||
link = cps_link_repo.get_by_code(db, orig_code)
|
||||
group_id = link.group_id if link else None
|
||||
nickname = headimgurl = unionid = None
|
||||
if kind == "uinfo" and "userinfo" in (token.get("scope") or ""):
|
||||
info = wx_oauth.get_userinfo(token["access_token"], openid)
|
||||
nickname, headimgurl, unionid = (
|
||||
info.get("nickname"), info.get("headimgurl"), info.get("unionid"),
|
||||
)
|
||||
cps_wx_user_repo.upsert(
|
||||
db, openid=openid, code=orig_code, group_id=group_id,
|
||||
nickname=nickname, headimgurl=headimgurl, unionid=unionid,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("[wx_oauth] callback failed state=%s", state)
|
||||
return RedirectResponse(f"/c/{orig_code}" if orig_code else _FALLBACK_URL, status_code=302)
|
||||
# userinfo 授权回来带 ?authed=1,落地页据此自动触发复制(把"点领券→授权→复制"衔接为一步)
|
||||
target = f"/c/{orig_code}" + ("?authed=1" if kind == "uinfo" else "")
|
||||
resp = RedirectResponse(target, status_code=302)
|
||||
resp.set_cookie(_WX_OPENID_COOKIE, openid, max_age=_COOKIE_MAX_AGE, httponly=True, samesite="lax")
|
||||
if nickname:
|
||||
resp.set_cookie(_WX_UINFO_COOKIE, "1", max_age=_COOKIE_MAX_AGE, samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/c/{code}/copy", summary="淘宝落地页点了「复制口令」(记 copy 事件,带 openid)")
|
||||
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")
|
||||
_record(db, link, request, "copy", openid=request.cookies.get(_WX_OPENID_COOKIE))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _taobao_landing_html(token: str, image_url: str) -> str:
|
||||
def _taobao_landing_html(
|
||||
token: str, image_url: str, code: str, openid: str | None, has_uinfo: bool
|
||||
) -> str:
|
||||
"""淘宝落地页 H5(主视觉图按活动传入,复制淘口令按钮在 75% 屏高)。
|
||||
|
||||
image_url 转绝对(落地页 coupon 域,相对也可达,绝对更稳)后经 html.escape 嵌入
|
||||
<img src>(防属性注入);token 经 json.dumps 安全嵌入 JS。
|
||||
image_url 经 html.escape 嵌入 <img src>(防注入);token 经 json.dumps 安全嵌入 JS。
|
||||
uinfo_url:已有 openid 但还没拿过昵称头像时,生成 userinfo 授权链接 —— 用户点「领券」
|
||||
时先跳它(交互触发,避免微信快照页),授权回来自动复制。已拿过 / 无 openid 则为空,直接复制。
|
||||
"""
|
||||
safe_img = html.escape(media.to_abs_media_url(image_url) or image_url, quote=True)
|
||||
uinfo_url = ""
|
||||
if openid and not has_uinfo and settings.wx_oauth_active:
|
||||
redirect_uri = f"{settings.CPS_REDIRECT_BASE.rstrip('/')}/wx/oauth/cb"
|
||||
uinfo_url = wx_oauth.build_authorize_url(redirect_uri, "snsapi_userinfo", f"uinfo:{code}")
|
||||
return (
|
||||
_TAOBAO_HTML
|
||||
.replace("__IMAGE_URL__", safe_img)
|
||||
.replace("__UINFO_URL__", json.dumps(uinfo_url))
|
||||
.replace("__TOKEN_JS__", json.dumps(token))
|
||||
)
|
||||
|
||||
@@ -104,6 +181,7 @@ body{font-family:-apple-system,"PingFang SC",sans-serif;background:#fff0ef;color
|
||||
<div class="toast" id="toast"></div>
|
||||
<script>
|
||||
var TOKEN = __TOKEN_JS__;
|
||||
var UINFO_URL = __UINFO_URL__; // 非空=还没拿昵称头像,点领券先跳它补(授权回来自动复制)
|
||||
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()}
|
||||
@@ -113,10 +191,18 @@ function fallback(){
|
||||
try{document.execCommand('copy');done()}catch(e){showToast('复制失败,请长按手动复制')}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
function copyToken(){
|
||||
function doCopy(){
|
||||
if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(TOKEN).then(done).catch(fallback)}
|
||||
else{fallback()}
|
||||
}
|
||||
function copyToken(){
|
||||
// 第一次领券且还没授权过昵称头像:先跳 userinfo 授权(用户点击=交互触发,避免快照页),
|
||||
// 授权回来 ?authed=1 自动复制;已授权过/无 openid 则直接复制。
|
||||
if(UINFO_URL){location.href=UINFO_URL;return}
|
||||
doCopy();
|
||||
}
|
||||
// userinfo 授权回流(?authed=1):自动复制,把"点领券→授权→复制"衔接成一步无感
|
||||
if(location.search.indexOf('authed=1')>=0){doCopy()}
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.repositories import ops_marquee as marquee_crud
|
||||
from app.repositories import ops_stat as crud
|
||||
from app.schemas.platform import (
|
||||
AppFlagsOut,
|
||||
AppVersionOut,
|
||||
PlatformStatsOut,
|
||||
SavingsFeedItem,
|
||||
SavingsFeedOut,
|
||||
@@ -51,3 +52,13 @@ def flags(db: DbSession) -> AppFlagsOut:
|
||||
return AppFlagsOut(
|
||||
comparing_ad_enabled=bool(app_config.get_value(db, "comparing_ad_enabled")),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/app-version", response_model=AppVersionOut, summary="最新 App 版本(OTA 检查更新,不鉴权)")
|
||||
def app_version(db: DbSession) -> AppVersionOut:
|
||||
"""客户端启动 / 手动检查更新时拉取。不鉴权:版本信息非敏感,且检查更新可能在登录前。
|
||||
用 latest_version_code 与本机 versionCode 比;未配置(返回默认 0)时客户端视为已是最新。"""
|
||||
data = app_config.get_app_version(db)
|
||||
if not data:
|
||||
return AppVersionOut()
|
||||
return AppVersionOut(**data)
|
||||
|
||||
@@ -95,6 +95,25 @@ class Settings(BaseSettings):
|
||||
"""美团 CPS 凭证齐全(缺则接口返空,而非 502)。"""
|
||||
return bool(self.MT_CPS_APP_KEY and self.MT_CPS_APP_SECRET)
|
||||
|
||||
# ===== 微信服务号(网页授权) =====
|
||||
# CPS 落地页在微信内拿用户 openid(base 静默)/昵称头像(userinfo),做用户级群统计。
|
||||
# ⚠️ 区别于 WECHAT_APP_ID(那是 App 移动应用,用于微信支付);这是【已认证服务号】。
|
||||
WX_MP_APPID: str = ""
|
||||
WX_MP_SECRET: str = ""
|
||||
# 落地页微信网页授权【总开关】。默认关:服务号认证审核中/未就绪时,落地页走原逻辑
|
||||
# (不跳授权、不拿 openid),整套微信代码保留。审核通过后 .env 置 true + 重启即启用,无需改代码。
|
||||
WX_MP_OAUTH_ENABLED: bool = False
|
||||
|
||||
@property
|
||||
def wx_mp_configured(self) -> bool:
|
||||
"""服务号网页授权凭证齐全(缺则落地页不发起授权,降级为无 openid)。"""
|
||||
return bool(self.WX_MP_APPID and self.WX_MP_SECRET)
|
||||
|
||||
@property
|
||||
def wx_oauth_active(self) -> bool:
|
||||
"""落地页是否真正发起微信授权 = 凭证齐全 且 总开关开。"""
|
||||
return self.wx_mp_configured and self.WX_MP_OAUTH_ENABLED
|
||||
|
||||
# ===== 微信支付(商家转账到零钱 / 提现)=====
|
||||
# 真实凭证放 .env(已 gitignore),证书 .pem 放 secrets/。WECHAT_APP_ID 同时用于
|
||||
# 微信登录(code 换 openid)与转账,必须与 App 端开放平台 appid 一致。
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""微信服务号网页授权:构造授权链接 + code 换 openid + 拉 userinfo。
|
||||
|
||||
用于 CPS 落地页(coupon.shaguabijia.com)在微信内拿用户身份。流程(见微信文档):
|
||||
授权链接(snsapi_base|snsapi_userinfo) → 回调带 code → sns/oauth2/access_token 换 openid
|
||||
→ (userinfo 时)sns/userinfo 拿昵称头像。
|
||||
|
||||
注意:
|
||||
- 这里的 access_token 是【网页授权 token】,与基础 access_token(cgi-bin/token)不同,
|
||||
走 sns/* 接口,不需要 IP 白名单。
|
||||
- secret 只在服务器,不下发客户端。
|
||||
- 用 WX_MP_APPID/SECRET(已认证服务号),区别于 WECHAT_APP_ID(App 移动应用)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
_API = "https://api.weixin.qq.com"
|
||||
_AUTHORIZE = "https://open.weixin.qq.com/connect/oauth2/authorize"
|
||||
_TIMEOUT = 10
|
||||
|
||||
|
||||
class WxOauthError(Exception):
|
||||
"""网页授权调用失败(凭证/网络/code 失效等)。调用方兜底为无 openid。"""
|
||||
|
||||
|
||||
def build_authorize_url(redirect_uri: str, scope: str, state: str) -> str:
|
||||
"""构造网页授权跳转链接。scope: 'snsapi_base'(静默,仅 openid) | 'snsapi_userinfo'(昵称头像)。
|
||||
|
||||
微信要求 redirect_uri urlEncode、参数顺序固定、结尾 #wechat_redirect。
|
||||
"""
|
||||
return (
|
||||
f"{_AUTHORIZE}?appid={settings.WX_MP_APPID}"
|
||||
f"&redirect_uri={quote(redirect_uri, safe='')}"
|
||||
f"&response_type=code&scope={scope}&state={quote(state, safe='')}"
|
||||
f"#wechat_redirect"
|
||||
)
|
||||
|
||||
|
||||
def exchange_code(code: str) -> dict:
|
||||
"""code 换网页授权 access_token + openid。返回含 openid/access_token/scope(/unionid)。
|
||||
|
||||
code 5 分钟内一次性有效。失败(errcode)抛 WxOauthError。
|
||||
"""
|
||||
resp = httpx.get(
|
||||
f"{_API}/sns/oauth2/access_token",
|
||||
params={
|
||||
"appid": settings.WX_MP_APPID,
|
||||
"secret": settings.WX_MP_SECRET,
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
},
|
||||
timeout=_TIMEOUT,
|
||||
)
|
||||
data = resp.json()
|
||||
if "openid" not in data:
|
||||
raise WxOauthError(f"exchange_code failed: {data}")
|
||||
return data
|
||||
|
||||
|
||||
def get_userinfo(access_token: str, openid: str) -> dict:
|
||||
"""拉用户信息(nickname/headimgurl/unionid)。需 scope=snsapi_userinfo 的 access_token。"""
|
||||
resp = httpx.get(
|
||||
f"{_API}/sns/userinfo",
|
||||
params={"access_token": access_token, "openid": openid, "lang": "zh_CN"},
|
||||
timeout=_TIMEOUT,
|
||||
)
|
||||
resp.encoding = "utf-8" # 昵称含中文/emoji,强制 utf-8 避免乱码
|
||||
data = resp.json()
|
||||
if "openid" not in data:
|
||||
raise WxOauthError(f"get_userinfo failed: {data}")
|
||||
return data
|
||||
@@ -21,6 +21,7 @@ from app.api.v1.compare_milestone import router as compare_milestone_router
|
||||
from app.api.v1.compare_record import router as compare_record_router
|
||||
from app.api.v1.coupon import router as coupon_router
|
||||
from app.api.v1.cps_redirect import router as cps_redirect_router
|
||||
from app.api.internal.app_version import router as internal_app_version_router
|
||||
from app.api.internal.price import router as internal_price_router
|
||||
from app.api.internal.store import router as internal_store_router
|
||||
from app.api.v1.feedback import router as feedback_router
|
||||
@@ -107,6 +108,7 @@ app.include_router(report_router)
|
||||
# 内部(server→server)端点:pricebot 上报价格观测 / 店铺映射,靠共享密钥头校验,不对客户端开放。
|
||||
app.include_router(internal_price_router)
|
||||
app.include_router(internal_store_router)
|
||||
app.include_router(internal_app_version_router)
|
||||
app.include_router(platform_router)
|
||||
# CPS 群发短链跳转 /c/{code}(公网无鉴权:记点击 → 302 跳美团)
|
||||
app.include_router(cps_redirect_router)
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.models.cps_activity import CpsActivity # noqa: F401
|
||||
from app.models.cps_group import CpsGroup # noqa: F401
|
||||
from app.models.cps_link import CpsClick, CpsLink # noqa: F401
|
||||
from app.models.cps_order import CpsOrder # noqa: F401
|
||||
from app.models.cps_wx_user import CpsWxUser # noqa: F401
|
||||
from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401
|
||||
from app.models.coupon_state import ( # noqa: F401
|
||||
CouponClaimRecord,
|
||||
|
||||
@@ -49,6 +49,8 @@ class CpsClick(Base):
|
||||
)
|
||||
ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
ua: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
# 微信网页授权拿到的用户标识(非微信/未授权为空),做用户级群统计
|
||||
openid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
clicked_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""CPS 落地页微信用户(cps_wx_user)。
|
||||
|
||||
用户在微信内打开群发短链落地页 /c/{code},经服务号网页授权:
|
||||
- base 静默:拿 openid(唯一标识,统计主力)
|
||||
- userinfo(点领券触发):补 nickname/headimgurl/unionid
|
||||
|
||||
按 openid 唯一,记录首次来源群(first_group_id),用于群内用户级统计(谁领了券)。
|
||||
下单归因到人需 user-level sid(另一期),本表只承载身份 + 领券/点击侧。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class CpsWxUser(Base):
|
||||
__tablename__ = "cps_wx_user"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
# 服务号下用户唯一标识(网页授权拿到)
|
||||
openid: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||
# 开放平台 unionid(服务号绑开放平台 + scope=userinfo 才有);跨 App/服务号统一用户
|
||||
unionid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
# 昵称/头像:userinfo 授权后才有(base 阶段为空)
|
||||
nickname: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
headimgurl: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
# 首次进入来源(从哪个群的链接授权进来)
|
||||
first_code: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
first_group_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
|
||||
first_seen: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
last_seen: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<CpsWxUser id={self.id} openid={self.openid!r} nickname={self.nickname!r}>"
|
||||
@@ -16,13 +16,10 @@ from app.repositories import wallet as crud_wallet
|
||||
|
||||
|
||||
FEED_REWARD_UNIT_SECONDS = 10
|
||||
# 单个 feed 事件的时长上限(秒):一期时长由客户端上报,伪造超长时长会刷份数
|
||||
# 单个 feed 事件的时长上限(秒):一期 duration_seconds 由客户端上报,伪造超长时长会刷份数
|
||||
# (每 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:
|
||||
@@ -45,89 +42,21 @@ def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
|
||||
).scalar_one()
|
||||
|
||||
|
||||
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
|
||||
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",
|
||||
)
|
||||
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,
|
||||
}
|
||||
).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
|
||||
|
||||
|
||||
def grant_feed_reward(
|
||||
@@ -135,8 +64,8 @@ def grant_feed_reward(
|
||||
user_id: int,
|
||||
*,
|
||||
client_event_id: str,
|
||||
segments: list[tuple[str, int]],
|
||||
ecpm_repr: str,
|
||||
ecpm: str,
|
||||
duration_seconds: int,
|
||||
ad_session_id: str | None = None,
|
||||
adn: str | None = None,
|
||||
slot_id: str | None = None,
|
||||
@@ -146,61 +75,102 @@ def grant_feed_reward(
|
||||
) -> AdFeedRewardRecord:
|
||||
"""比价/领券一整场信息流(轮播多条)结束后结算奖励。client_event_id 幂等,同号重试不重复发。
|
||||
|
||||
发奖规则:**比价全程不关广告才发**,金额按整场逐条 [segments]「跨条进位」折份
|
||||
(每 10 秒 1 份,每份用所在段 eCPM,见 [_walk_unit_ecpms])。
|
||||
发奖规则:**比价全程不关广告才发**,金额按整场**总观看时长**折份(每 10 秒 1 份)。
|
||||
- aborted=True(用户中途 ✕ 关闭):整场不发,记 status='closed_early' 留痕(原因可查)。
|
||||
- 总时长不足 10 秒(unit_count==0):记 status='too_short' 不发。
|
||||
- 命中当日条数上限:记 status='capped' 不发。
|
||||
segments 是逐条广告 `(ecpm, seconds)` 时序列表(旧客户端只传总时长时,API 层包成单段)。
|
||||
ecpm_repr 是落库代表 eCPM(报表/审计用,通常取末段)。三道硬闸防刷:份数封顶 [FEED_MAX_UNITS]
|
||||
限单场份数,eCPM 在 rewards.calculate_ad_reward_coin 内钳到 AD_ECPM_MAX_FEN 限单份金额,
|
||||
叠加每日 get_ad_daily_limit 条数上限,把单用户日产出锁进有限区间。
|
||||
duration_seconds 是整场累计秒数。一期 eCPM/时长均由客户端上报,故服务端两道硬闸防刷:时长钳到
|
||||
FEED_MAX_DURATION_SECONDS 限单场份数,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()
|
||||
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)
|
||||
# 客户端上报时长先钳到 FEED_MAX_DURATION_SECONDS,防伪造超长时长刷份数(见常量注释)。
|
||||
safe_duration = max(0, min(duration_seconds, FEED_MAX_DURATION_SECONDS))
|
||||
unit_count = safe_duration // FEED_REWARD_UNIT_SECONDS
|
||||
|
||||
def _record(coin_val: int, status: str) -> AdFeedRewardRecord:
|
||||
return AdFeedRewardRecord(
|
||||
# 用户中途关闭广告:整场不发(全程不关才发),留一条 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_repr,
|
||||
ecpm_raw=ecpm,
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
app_env=app_env,
|
||||
our_code_id=our_code_id,
|
||||
coin=coin_val,
|
||||
status=status,
|
||||
coin=0,
|
||||
status="closed_early",
|
||||
)
|
||||
|
||||
# 用户中途关闭广告:整场不发(全程不关才发),留一条 closed_early 记录原因。优先级最高。
|
||||
if aborted:
|
||||
return _commit_record(db, _record(0, "closed_early"), client_event_id)
|
||||
return _commit_record(db, rec, client_event_id)
|
||||
|
||||
if _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db):
|
||||
return _commit_record(db, _record(0, "capped"), client_event_id)
|
||||
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)
|
||||
|
||||
# 整场总时长不足 10 秒,凑不满一份 → 不发,记 too_short 留痕。
|
||||
if unit_count == 0:
|
||||
return _commit_record(db, _record(0, "too_short"), client_event_id)
|
||||
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)
|
||||
|
||||
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}份",
|
||||
)
|
||||
return _commit_record(db, _record(coin, "granted"), client_event_id)
|
||||
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)
|
||||
|
||||
|
||||
def _commit_record(db: Session, rec: AdFeedRewardRecord, client_event_id: str) -> AdFeedRewardRecord:
|
||||
|
||||
@@ -63,3 +63,31 @@ def list_all(db: Session) -> list[dict]:
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
# ── 最新 App 版本(OTA 检查更新)──────────────────────────────────────────────
|
||||
# 版本信息不是"运营可配置项",不进 CONFIG_DEFS:它由发布流程(发车)/应急整组写入、客户端读取做
|
||||
# OTA,语义是"发布系统状态"而非"运营调参"。故走下面专用读写,直接操作 AppConfig 表(复用表不复用 repo)。
|
||||
APP_VERSION_KEY = "latest_app_version"
|
||||
|
||||
|
||||
def get_app_version(db: Session) -> dict | None:
|
||||
"""读最新 App 版本信息(OTA)。无记录返回 None(客户端视为无更新)。"""
|
||||
row = db.get(AppConfig, APP_VERSION_KEY)
|
||||
return row.value if row is not None else None
|
||||
|
||||
|
||||
def set_app_version(db: Session, data: dict, *, commit: bool = True) -> AppConfig:
|
||||
"""整组原子覆盖最新版本信息(发布流程/应急写,非 admin,故 updated_by_admin_id 留空)。"""
|
||||
row = db.get(AppConfig, APP_VERSION_KEY)
|
||||
if row is None:
|
||||
row = AppConfig(key=APP_VERSION_KEY, value=data, updated_by_admin_id=None)
|
||||
db.add(row)
|
||||
else:
|
||||
row.value = data
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
else:
|
||||
db.flush()
|
||||
return row
|
||||
|
||||
@@ -52,12 +52,13 @@ def create_link(
|
||||
|
||||
def record_click(
|
||||
db: Session, *, link: CpsLink, ip: str | None = None, ua: str | None = None,
|
||||
event_type: str = "visit",
|
||||
event_type: str = "visit", openid: str | None = None,
|
||||
) -> None:
|
||||
"""记一条点击。event_type: visit(进落地页/被跳转) | copy(淘宝点了"复制口令")。"""
|
||||
"""记一条点击。event_type: visit(进落地页/被跳转) | copy(淘宝点了"复制口令")。
|
||||
openid: 微信网页授权拿到的用户标识(非微信/未授权为空),做用户级群统计。"""
|
||||
db.add(CpsClick(
|
||||
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),
|
||||
ip=ip, ua=(ua[:500] if ua else None), openid=openid,
|
||||
))
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""cps_wx_user 数据访问:按 openid upsert。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.cps_wx_user import CpsWxUser
|
||||
|
||||
|
||||
def get_by_openid(db: Session, openid: str) -> CpsWxUser | None:
|
||||
return db.execute(
|
||||
select(CpsWxUser).where(CpsWxUser.openid == openid)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def upsert(
|
||||
db: Session, *, openid: str, code: str | None = None, group_id: int | None = None,
|
||||
nickname: str | None = None, headimgurl: str | None = None, unionid: str | None = None,
|
||||
) -> CpsWxUser:
|
||||
"""按 openid upsert。
|
||||
|
||||
- 首次:建行,记来源 code/group。
|
||||
- 已存在:仅在传入非 None 时更新昵称/头像/unionid(base 阶段为 None,不覆盖已有画像);
|
||||
并刷新 last_seen(显式 set,因 onupdate 仅在字段有变更时触发)。
|
||||
"""
|
||||
u = get_by_openid(db, openid)
|
||||
if u is None:
|
||||
u = CpsWxUser(
|
||||
openid=openid, first_code=code, first_group_id=group_id,
|
||||
nickname=nickname, headimgurl=headimgurl, unionid=unionid,
|
||||
)
|
||||
db.add(u)
|
||||
else:
|
||||
if nickname is not None:
|
||||
u.nickname = nickname
|
||||
if headimgurl is not None:
|
||||
u.headimgurl = headimgurl
|
||||
if unionid is not None:
|
||||
u.unionid = unionid
|
||||
u.last_seen = func.now()
|
||||
db.commit()
|
||||
db.refresh(u)
|
||||
return u
|
||||
+4
-49
@@ -120,40 +120,19 @@ 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)。
|
||||
|
||||
计价口径:优先用 [segments] 逐条精确结算(每条自带 eCPM);未传 segments 时回退用
|
||||
ecpm + duration_seconds 当**单段**(旧客户端兼容)。两者都没有则 422。
|
||||
规则:全程不关广告才发,金额按整场**总观看时长**折份(每 10 秒 1 份)。client_event_id 用于
|
||||
客户端超时重试幂等。中途被用户关闭时传 aborted=True,整场不发(只记 closed_early)。
|
||||
"""
|
||||
|
||||
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"
|
||||
)
|
||||
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 时忽略"
|
||||
)
|
||||
ecpm: str = Field(..., description="本场信息流 eCPM(代表值,按分/千次展示处理;SDK getEcpm 原值,非元)")
|
||||
duration_seconds: int = Field(..., ge=0, description="整场累计观看秒数(轮播各条相加)")
|
||||
adn: str | None = Field(None, description="实际投放 ADN")
|
||||
slot_id: str | None = Field(None, description="实际展示代码位")
|
||||
app_env: str | None = Field(
|
||||
@@ -175,30 +154,6 @@ 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):
|
||||
"""激励视频展示了但用户提前关闭/跳过、未触发发奖——上报留痕(不发金币)。
|
||||
|
||||
|
||||
+29
-1
@@ -1,7 +1,7 @@
|
||||
"""首页平台级展示数据 schemas(客户端首页门面数字)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PlatformStatsOut(BaseModel):
|
||||
@@ -28,3 +28,31 @@ class AppFlagsOut(BaseModel):
|
||||
"""客户端拉取的运营 feature flag(不鉴权,登录前也能拉)。客户端缓存后按需读。"""
|
||||
|
||||
comparing_ad_enabled: bool # 比价/领券期是否展示信息流广告(远程 kill-switch)
|
||||
|
||||
|
||||
class AppVersionOut(BaseModel):
|
||||
"""最新 App 版本信息(OTA 检查更新,不鉴权)。
|
||||
|
||||
客户端用 latest_version_code(整数,单调递增)与本机 versionCode 比较,**不要**用 version_name 字符串比。
|
||||
latest_version_code=0 表示后台尚未配置任何版本 → 客户端一律视为"已是最新"。
|
||||
"""
|
||||
|
||||
latest_version_code: int = 0 # 最新版 versionCode;0=未配置(无更新)
|
||||
latest_version_name: str = "" # 展示用,如 "0.1.4"
|
||||
apk_url: str = "" # 下载链接(发车产出的永久版本化链接)
|
||||
update_note: str = "" # 更新说明,弹窗展示
|
||||
min_supported_version_code: int = 0 # 本机低于此版本=强制更新;0=不强更(全可选)
|
||||
apk_size_bytes: int = 0 # 包大小(字节),展示"约 xMB"
|
||||
apk_sha256: str = "" # APK 完整性校验(客户端下载后比对,防损坏/劫持)
|
||||
|
||||
|
||||
class AppVersionWriteIn(BaseModel):
|
||||
"""内部写入最新版本(发布流程/应急,X-Internal-Secret 校验)。整组原子覆盖。"""
|
||||
|
||||
latest_version_code: int = Field(gt=0)
|
||||
latest_version_name: str = Field(min_length=1)
|
||||
apk_url: str = Field(min_length=1)
|
||||
update_note: str = ""
|
||||
min_supported_version_code: int = 0
|
||||
apk_size_bytes: int = 0
|
||||
apk_sha256: str = ""
|
||||
|
||||
+9
-6
@@ -3,7 +3,7 @@
|
||||
> Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770`
|
||||
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**
|
||||
> 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer <access_token>`
|
||||
> 最后更新:2026-06-07(金币数值体系一期:签到膨胀 + 信息流广告结算)
|
||||
> 最后更新:2026-06-18(补录比价透传端点 `intent/precoupon/step`、`intent/step`、`trace/finalize` 与 `meituan/top-sales` 到总览;上一次功能更新 2026-06-07 金币数值体系一期)
|
||||
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
|
||||
|
||||
---
|
||||
@@ -23,9 +23,13 @@
|
||||
| 9 | `POST /api/v1/meituan/coupons` | 无 | [详情](./meituan-coupons.md) |
|
||||
| 10 | `POST /api/v1/meituan/feed` | 无 | [详情](./meituan-feed.md) |
|
||||
| 11 | `POST /api/v1/meituan/referral-link` | 无 | [详情](./meituan-referral-link.md) |
|
||||
| **比价透传**(前缀 `/api/v1`,外卖 MVP;与 `coupon/step` 同为透传 pricebot-backend) |||
|
||||
| 12 | `POST /api/v1/intent/recognize` | 无 | [详情](./compare-intent-recognize.md) |
|
||||
| 13 | `POST /api/v1/price/step` | 无 | [详情](./compare-price-step.md) |
|
||||
| 11a | `POST /api/v1/meituan/top-sales` | 无 | [详情](./meituan-top-sales.md)(销量榜:离线库 `meituan_coupon` 按销量降序 + 跨源去重,不实时打美团) |
|
||||
| **比价透传**(前缀 `/api/v1`,外卖 MVP;与 `coupon/step` 同为透传 pricebot-backend;下按 Phase 流程列,均不鉴权) |||
|
||||
| 12 | `POST /api/v1/intent/recognize` | 无 | [详情](./compare-intent-recognize.md)(Phase 1 意图识别,单次,多数源) |
|
||||
| 12a | `POST /api/v1/intent/precoupon/step` | 无 | Phase 0 意图识别前先用券,仅美团源(透传,无单独文档) |
|
||||
| 12b | `POST /api/v1/intent/step` | 无 | Phase 1 多帧意图识别,仅淘宝源,循环到 done(透传,无单独文档) |
|
||||
| 13 | `POST /api/v1/price/step` | 无 | [详情](./compare-price-step.md)(Phase 2 步进) |
|
||||
| 13a | `POST /api/v1/trace/finalize` | 无 | 比价 trace 收尾上云,终止/未识别拿 trace_url(透传,无单独文档) |
|
||||
| **比价记录**(前缀 `/api/v1/compare`;按用户落库,**鉴权**,区别于上面不鉴权的透传) |||
|
||||
| 12a | `POST /api/v1/compare/record` | Bearer | [详情](./compare-record-report.md) |
|
||||
| 12b | `GET /api/v1/compare/records` | Bearer | [详情](./compare-records.md) |
|
||||
@@ -63,7 +67,6 @@
|
||||
| 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) |
|
||||
@@ -110,7 +113,7 @@
|
||||
| - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) |
|
||||
|
||||
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
|
||||
> `coupon/step` 及外卖比价的 `intent/recognize` / `price/step` 都透传到 pricebot-backend,**MVP 阶段均不鉴权**(device_id 透传,待补 JWT——见各接口详情)。
|
||||
> `coupon/step` 及外卖比价的 `intent/recognize`、`intent/precoupon/step`、`intent/step`、`price/step`、`trace/finalize` 都透传到 pricebot-backend,**MVP 阶段均不鉴权**(device_id 透传,待补 JWT——见 `app/api/v1/compare.py`)。
|
||||
> 福利相关业务接口(wallet/signin/tasks/savings、`ad/reward-status`、`ad/feed-reward`)均需 **Bearer**;`wallet/exchange-info` 是静态规则无鉴权;`ad/pangle-callback` 不走 JWT、靠穿山甲**验签**;`ad/test-grant` **仅本地联调**(开关控制,生产 404)。
|
||||
> 金额字段一律以**分**为单位(`*_cents`)。
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# 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`)。
|
||||
+10
-19
@@ -1,8 +1,6 @@
|
||||
# POST /api/v1/ad/feed-reward — 信息流广告完成后结算金币
|
||||
|
||||
点位 2:比价等待 / 领券信息流广告(轮播多条)。**整场比价全程不关广告才发**,金额按整场折份(每 10 秒 1 份),结束时一次性入账。用户中途 ✕ 关闭则整场不发。
|
||||
|
||||
播放过程中查询「此刻将发放多少金币」(金币小球实时显示用)见 [feed-reward/preview](./ad-feed-reward-preview.md),与本接口同一计价口径但只读不入账。
|
||||
点位 2:比价等待 / 领券信息流广告(轮播多条)。**整场比价全程不关广告才发**,金额按整场**总观看时长**折份(每 10 秒 1 份),结束时一次性入账。用户中途 ✕ 关闭则整场不发。
|
||||
|
||||
## 鉴权
|
||||
|
||||
@@ -10,44 +8,37 @@
|
||||
|
||||
## 请求体
|
||||
|
||||
计价优先用 `segments` 逐条精确结算;未传 `segments` 时回退用 `ecpm` + `duration_seconds` 当**单段**(旧客户端兼容)。两者都不传 → 422。
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---:|---|
|
||||
| `client_event_id` | string | 是 | 客户端生成的幂等事件 id,8-64 字符 |
|
||||
| `ad_session_id` | string\|null | 否 | 客户端生成的一次信息流广告会话 id,用于对账/排查 |
|
||||
| `segments` | array\|null | 否* | **逐条广告段**(时序),每项 `{ecpm: string, seconds: int}`。新客户端传它做按条精确结算 |
|
||||
| `ecpm` | string\|null | 否* | **[回退]** 本场代表 eCPM(穿山甲 getEcpm 原值,分/千次展示,非元);传了 `segments` 时仅作落库代表值 |
|
||||
| `duration_seconds` | int | 否 | **[回退]** 整场累计观看秒数;传了 `segments` 时忽略。默认 0 |
|
||||
| `ecpm` | string | 是 | 本场信息流 eCPM 代表值(穿山甲 getEcpm 原值),按“分/千次展示”处理(非元) |
|
||||
| `duration_seconds` | int | 是 | **整场累计观看秒数**(轮播各条相加) |
|
||||
| `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 | 折算出的奖励份数(封顶 12) |
|
||||
| `unit_count` | int | 按 10 秒折算出的奖励份数 |
|
||||
| `daily_limit` | int | 每日信息流展示次数上限,默认 500 |
|
||||
|
||||
## 计算口径
|
||||
|
||||
- **奖励份数(跨条进位)**:按 `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)`,结果与改造前一致。
|
||||
- 奖励份数:`整场总时长 // 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`。
|
||||
|
||||
## 数据写入
|
||||
|
||||
- `ad_feed_reward_record` 新增一行(`ecpm_raw` 存代表 eCPM,通常为末段)。
|
||||
- `ad_feed_reward_record` 新增一行。
|
||||
- 入账时 `coin_account` 增加余额。
|
||||
- 入账时 `coin_transaction` 写入 `biz_type=feed_ad_reward`。
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# cps_activity — CPS 可推广活动池(运营预存的券/物料)
|
||||
|
||||
> 模型 `app/models/cps_activity.py` · 仓库 `app/admin/repositories/cps.py`(`create_activity` / `list_activities` / `get_activity` / `delete_activity` / `list_activity_images`) · 接口 admin `POST /admin/api/cps/activities`、`GET /admin/api/cps/activities`、`DELETE /admin/api/cps/activities/{id}`、`POST /admin/api/cps/upload-image`、`GET /admin/api/cps/activity-images` · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
运营把常推的活动(券)预存成池,生成群发链接时从池里挑,省得每次记物料 ID。数据**全由运营在后台手填/上传**,无外部同步。在 CPS 群发漏斗里它是**最上游的"卖什么"**:活动(本表)× 群([`cps_group`](./cps_group.md))经"批量生成短链"组合出一条条 [`cps_link`](./cps_link.md)。一条活动可被多个群、多次复用生成 link(一对多,无外键)。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C(插入)**:`POST /admin/api/cps/activities`(`create_activity`,需 `operator` 角色)。按 `platform` 分支校验:`meituan` 必须填 `act_id` 或 `product_view_sign` 之一;`taobao`/`jd` 必须填 `payload`,且 `taobao` 还必须有 `image_url`(落地页图)。淘宝图经 `POST /admin/api/cps/upload-image`(`media.save_cps_image`)上传得绝对 URL,或经 `GET /admin/api/cps/activity-images` 选已有。无幂等键,重复提交建多条。
|
||||
- **U(更新)**:**无更新端点**。改活动 = 删了重建。`status`(active/archived)列存在但目前没有切换它的路由(待确认是否前端规划)。
|
||||
- **R(读)**:`GET /admin/api/cps/activities`(`list_activities`,游标分页,可按 `platform`/`status` 过滤,登录即可);生成 link 时 `get_activity` 按 id 取;淘宝落地页渲染时 `cps_redirect` 用 `db.get(CpsActivity, link.activity_id)` 取 `image_url`;`list_activity_images` 取所有 distinct 非空 `image_url` 供新建复用。
|
||||
- **D(删除)**:`DELETE /admin/api/cps/activities/{id}`(`delete_activity`,`operator`)。**硬删,不级联** `cps_link`——已发出去的短链继续可用;淘宝落地页若取不到活动(被删),`cps_redirect` 用兜底默认图 `/media/taobao_landing.jpg`。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | 被 `cps_link.activity_id` 语义引用(非 FK) |
|
||||
| `platform` | String(20) | NOT NULL, default `meituan` | `meituan` / `taobao` / `jd`。决定转链/落地方式,也决定下面哪些字段必填 |
|
||||
| `name` | String(128) | NOT NULL | 活动名,运营自定,后台列表/审计展示 |
|
||||
| `act_id` | String(64) | nullable | **美团**联盟「我要推广-活动推广」第一列的物料 ID(actId)。转链 `get_referral_link` 入参 |
|
||||
| `product_view_sign` | String(128) | nullable | **美团**商品券 productViewSign,与 `act_id` 二选一转链(多数活动用 `act_id`) |
|
||||
| `payload` | Text | nullable | **淘宝**=整段淘口令文本(落地页原样供复制);**京东**=推广链接(落地页 302 跳)。美团不用此列(用 `act_id`)。生成 link 时直接作 `target_url` |
|
||||
| `image_url` | String(512) | nullable | **淘宝**落地页主视觉图,存绝对 URL(基于 `CPS_REDIRECT_BASE`,跨域 admin-web 可显示)。落地页按活动展示对应图 |
|
||||
| `status` | String(20) | NOT NULL, default `active` | `active` / `archived`(目前无切换入口,主要靠删) |
|
||||
| `remark` | String(256) | nullable | 运营备注 |
|
||||
| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 创建时间。**无 `updated_at`**(本表无更新路径) |
|
||||
|
||||
## 关系 / Join Key
|
||||
- `id` ← `cps_link.activity_id`(一活动多 link,**语义关联,无 ForeignKey**;删活动不级联删 link)。
|
||||
- `id` ← `cps_redirect` 渲染淘宝落地页时按 `link.activity_id` 反查本表取 `image_url`。
|
||||
- 与 [`cps_group`](./cps_group.md) 无直接列关系:两者在"批量生成短链"(`generate_referral_links`)时按 **platform 是否在群范围内**组合,产物是 [`cps_link`](./cps_link.md)。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`。
|
||||
- **无唯一约束、无额外索引、无外键**(运营低频、量级小,按 id 主键访问即可)。
|
||||
|
||||
## 注意
|
||||
- **`platform` 是多平台演进的核心**:初版(commit `277f9b1`)只有美团,字段就是 `act_id` + `product_view_sign`;`3a40f61` 接淘宝/京东时**复用本表**,只加了 `payload`(异构标识塞一列);`d55f47f` 再加 `image_url` 把淘宝落地页图"做活"(此前是写死一张图)。所以三平台的"卖点"挤在同一张表,靠 `platform` 分流——这是省表的刻意取舍。
|
||||
- **美团二选一**:`act_id` 与 `product_view_sign` 对应 `get_referral_link` 的两种转链入参,router 层强制至少填一个。
|
||||
- **淘宝必须有图**:`taobao` 落地页是我们自渲染的 H5(`cps_redirect._taobao_landing_html`),没图页面就空,故建活动时强制。`d55f47f` 的迁移把存量淘宝活动的 `image_url` 回填成此前写死的 `/media/taobao_landing.jpg`,无缝衔接。
|
||||
- **`image_url` 存绝对 URL 的原因**:落地页域(`CPS_REDIRECT_BASE`)与 admin-web 跨域,存绝对值两边都能取(`media.to_abs_media_url`)。dev 未配 `CPS_REDIRECT_BASE` 时退化为相对路径。
|
||||
@@ -0,0 +1,37 @@
|
||||
# cps_click — CPS 短链点击事件(/c/{code} 每次访问/复制记一条)
|
||||
|
||||
> 模型 `app/models/cps_link.py`(`CpsClick`,与 `CpsLink` 同文件) · 仓库 `app/repositories/cps_link.py`(`record_click` / `click_stats_by_group`) · 接口 用户 `GET /c/{code}`(记 `visit`)、`POST /c/{code}/copy`(记 `copy`,`app/api/v1/cps_redirect.py`);统计 admin `GET /admin/api/cps/stats` · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
群发短链 [`cps_link`](./cps_link.md) 每被点一次就在此记一条。在 CPS 群发漏斗里它是**"用户点了"这一步**:用户点 `/c/{code}` 进落地/被跳转记 `visit`,淘宝落地页点"复制口令"记 `copy`。据此统计点击 PV/UV(按群/活动/时段),配合 [`cps_order`](./cps_order.md) 的下单/佣金构成"点击→下单→佣金"漏斗。`group_id`/`sid` 在本表**冗余一份**(来自 link),统计直接按群聚合、不必 join `cps_link`。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C(插入)**:`record_click`(`app/repositories/cps_link.py`),由 `cps_redirect` 两处触发——`GET /c/{code}`(`cps_landing`)记 `event_type=visit`;`POST /c/{code}/copy`(`cps_copy`,淘宝落地页点复制按钮 fetch 回调)记 `event_type=copy`。写入时把 link 的 `group_id`/`sid` 冗余进来,`ua` 截断到 500。**记点击失败被 `_record` 吞掉绝不阻断用户跳转/落地**(公网无鉴权,任何人点都要能跳)。无幂等——每次点都写新行(PV 即行数)。
|
||||
- **U / D**:**无**。点击是只追加的事件流。
|
||||
- **R(读)**:`click_stats_by_group`(`GET /admin/api/cps/stats` 经 `group_stats` 调用)按 `group_id` 聚合 → `{pv, uv, copy_pv, copy_uv}`;UV 按 `(ip, ua)` 近似去重,`visit` 计 pv/uv、`copy` 计 copy_pv/copy_uv。聚合在 Python 侧做(量级小、跨 PG/SQLite 无方言坑)。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `link_id` | Integer | index, NOT NULL | 来源短链 = `cps_link.id`(语义关联,非 FK) |
|
||||
| `group_id` | Integer | index, NOT NULL | **冗余**自 link.group_id;按群聚合点击快,免 join cps_link |
|
||||
| `sid` | String(64) | index, **nullable** | **冗余**自 link.sid;美团有、淘宝/京东无 |
|
||||
| `event_type` | String(16) | NOT NULL, default/server_default `visit` | `visit`(进落地页/被 302 跳) / `copy`(淘宝落地页点了"复制口令") |
|
||||
| `ip` | String(64) | nullable | 客户端 IP(取 `X-Forwarded-For` 首段,否则 `request.client.host`),UV 去重键之一 |
|
||||
| `ua` | String(512) | nullable | User-Agent,入库截断到 500;UV 去重键之一 |
|
||||
| `clicked_at` | DateTime(tz) | server_default now(), index, NOT NULL | 点击时间,统计按时间窗过滤 |
|
||||
|
||||
## 关系 / Join Key
|
||||
- `link_id` → `cps_link.id`(多对一,**语义关联,无 ForeignKey**)。
|
||||
- `group_id` → `cps_group.id`、`sid` ≈ `cps_group.sid`——均**冗余字段**,直接按群聚合用,非 FK。
|
||||
- 与 [`cps_order`](./cps_order.md) 无直接关联:点击与订单在 `group_stats` 里**各按 `group_id`/`sid` 聚合后并到同一群行**,共同组成漏斗,但两表之间无 join key(无法把某次点击对到某笔订单——CPS 联盟不回传点击 id)。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`;index `link_id`、`group_id`、`sid`、`clicked_at`。
|
||||
- **无唯一约束、无外键**(纯事件流,PV 即行数,允许重复)。
|
||||
|
||||
## 注意
|
||||
- **`event_type` 是接淘宝时新增的**:初版(`277f9b1`)只有 visit(点了就 302 跳美团,记一条即可);`3a40f61` 接淘宝后,淘宝落地页是 H5、用户要点按钮"复制口令"才算转化,故迁移 `cps_v2_platforms` 加 `event_type`(server_default `visit` 回填存量),`copy` 单独统计成 `copy_pv/copy_uv`。同迁移把 `sid` 改 nullable(淘宝/京东点击无 sid)。
|
||||
- **`group_id`/`sid` 冗余的理由**:统计是高频读路径,直接 `WHERE group_id=?` 聚合,省掉 `cps_click → cps_link` 的 join;代价是写入时多带两列(`record_click` 从 link 拷)。这与 model docstring 明说的"统计直接按群聚合、不必 join cps_link"一致。
|
||||
- **UV 是近似值**:按 `(ip, ua)` 去重,无 Cookie/设备指纹;同一人换网络/浏览器会重复计 UV。够运营看趋势,不作精确口径。
|
||||
- **记点击绝不阻断**:`_record` 用 `try/except: pass` 包住,落地/跳转优先级高于统计——丢几条点击可接受,挡住用户领券不可接受。
|
||||
@@ -0,0 +1,38 @@
|
||||
# cps_group — CPS 推广群(一个微信群一条,sid 是美团渠道追踪位)
|
||||
|
||||
> 模型 `app/models/cps_group.py` · 仓库 `app/admin/repositories/cps.py`(`create_group` / `update_group` / `delete_group` / `get_group` / `get_group_by_sid` / `list_groups`、统计 `group_stats`) · 接口 admin `POST/GET/PATCH/DELETE /admin/api/cps/groups`、统计 `GET /admin/api/cps/stats` · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
每个发券的微信群一条记录。核心字段是 `sid`——**美团联盟二级渠道追踪位**:发链接时塞进推广链接(`get_referral_link`),订单回来按 `sid` 归到这个群对账(`query_order` → [`cps_order`](./cps_order.md))。在 CPS 群发漏斗里它是**"发给谁/在哪个渠道"**:群(本表)× 活动([`cps_activity`](./cps_activity.md))组合生成 [`cps_link`](./cps_link.md);点击([`cps_click`](./cps_click.md))与订单都靠 `sid`/`group_id` 归到本群,统计接口 `group_stats` 即以群为行做"点击→下单→佣金"漏斗。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C(插入)**:`POST /admin/api/cps/groups`(`create_group`,`operator`)。`platforms` 至少选一个,非法平台 400。**`sid` 仅当 `platforms` 含 `meituan` 时才有**:运营填了就用(校验全局唯一,重复 409),没填则先占位 `tmp<uuid>`、flush 拿到 id 后回填成 `g<id>`;纯淘宝/京东群 `sid` 恒 `None`(那俩平台不支持 sid)。无业务幂等键(同名可重复建),唯一性只在 `sid` 上。
|
||||
- **U(更新)**:`PATCH /admin/api/cps/groups/{id}`(`update_group`,`operator`)。可改 `name`/`platforms`/`member_count`/`status`/`remark`。**`sid` 不可改**(改了会断开历史订单的归群)。
|
||||
- **R(读)**:`GET /admin/api/cps/groups`(`list_groups`,游标分页,`keyword` 模糊匹配 name 或 sid、`status` 过滤);生成 link 时 `get_group` 取群拿 `sid`;统计 `group_stats` 遍历所有群、按 `sid` 关联订单、按 `group_id` 关联点击。
|
||||
- **D(删除)**:`DELETE /admin/api/cps/groups/{id}`(`delete_group`,`operator`)。**硬删,不级联**已生成的 [`cps_link`](./cps_link.md)(已发链接继续可用);该群历史 [`cps_click`](./cps_click.md)/[`cps_order`](./cps_order.md) 按 `sid`/`group_id` 仍在库,删群后这些 sid 在统计里降级为"未归群"行(`group_id=None`)。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | 被 `cps_link.group_id`、`cps_click.group_id` 语义引用(非 FK) |
|
||||
| `sid` | String(64) | **UNIQUE, index, nullable** | 美团渠道追踪位,仅字母+数字 ≤64(美团限制,schema 正则 `^[A-Za-z0-9]+$`)。**仅含 meituan 的群有**;留空自动 `g<id>`;纯淘宝/京东群为 `None`。订单/点击按它归群 |
|
||||
| `name` | String(128) | NOT NULL | 群名,运营自定 |
|
||||
| `platforms` | JSON / JSONB | NOT NULL, default `[]` | 该群发哪些平台(多选):`["meituan","taobao","jd"]`。含 `meituan` 才需要 `sid`。生成 link 时校验"活动平台必须在群范围内" |
|
||||
| `member_count` | Integer | nullable | 群人数,运营填,作转化率分母(可空) |
|
||||
| `status` | String(20) | NOT NULL, default `active` | `active` / `archived`。统计 `group_stats` 只列 `active` 群 |
|
||||
| `remark` | String(256) | nullable | 运营备注 |
|
||||
| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 创建时间(无 `updated_at`) |
|
||||
|
||||
## 关系 / Join Key
|
||||
- `id` ← `cps_link.group_id`、`cps_click.group_id`(一群多 link/多点击,**语义关联,无 ForeignKey**)。
|
||||
- `sid` ← `cps_order.sid`(订单按 sid 归群对账,**语义关联,非 FK**);`sid` ← `cps_link.sid` / `cps_click.sid`(生成 link 时把群 sid 冗余进 link/click)。
|
||||
- 与 [`cps_activity`](./cps_activity.md) 无直接列关系,经"批量生成短链"按平台组合。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`;**UNIQUE+index `sid`**(`uq_cps_group_sid` + `ix_cps_group_sid`)。
|
||||
- **无外键**。
|
||||
|
||||
## 注意
|
||||
- **`sid` 从"必填"放宽到"可空"是多平台演进的关键改动**:初版(`277f9b1`)`sid` NOT NULL + UNIQUE(每个美团群必有渠道位);`3a40f61` 接淘宝/京东后,这俩平台**没有 sid 概念**,迁移 `cps_v2_platforms` 把 `sid` 改 nullable,并加 `platforms` 列(存量群 server_default 回填 `["meituan"]`)。理解本表必须记住:**sid 只对美团有意义**,对账/归群全链路只走美团群。
|
||||
- **`g<id>` 自动 sid**:运营不填 sid 时服务端生成 `g<群id>`,既保证唯一又人类可读。先写 `tmp<uuid>` 占位是因为建群事务里 id 还没生成(flush 前拿不到),拿到 id 后二次 flush 回填。
|
||||
- **删群不级联**是刻意的:已发到微信群里的短链不能因为后台删群就失效;历史订单/点击也要保留用于对账。代价是删群后其 `sid` 的订单在 `group_stats` 里变成"未归群"独立行(如历史的 `wonderableai`,见 [`cps_order`](./cps_order.md) 注意)。
|
||||
- **`platforms` 用 JSON 列**:遵循全库约定 `JSON().with_variant(JSONB(), "postgresql")`——PG 上 JSONB,SQLite 退化通用 JSON。
|
||||
@@ -0,0 +1,38 @@
|
||||
# cps_link — CPS 群发短链(发群用的 /c/{code},外套一层我们的短链)
|
||||
|
||||
> 模型 `app/models/cps_link.py`(`CpsLink`) · 仓库 `app/repositories/cps_link.py`(`create_link` / `get_by_code`) · 接口 admin 生成 `POST /admin/api/cps/referral-links`、用户落地 `GET /c/{code}`(`app/api/v1/cps_redirect.py`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
群发券链接外套一层我们自己的短链 `/c/{code}`,目的是**统计点击 + 控制落地**。在 CPS 群发漏斗里它是**"发出去的那条链接"**:由 群([`cps_group`](./cps_group.md))× 活动([`cps_activity`](./cps_activity.md))在后台"批量生成短链"产出,运营把 `/c/{code}` 复制到微信群。用户一点 → 记一条 [`cps_click`](./cps_click.md) → 按平台 302 跳 `target_url`(美团短链/京东链接)或返回淘宝落地页。`cps_click` 与 `cps_link` 同文件(`models/cps_link.py`),详见 [`cps_click.md`](./cps_click.md)。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C(插入)**:`POST /admin/api/cps/referral-links`(`generate_referral_links`,`operator`)批量生成——按 `group_id` + 一组 `activity_ids`,对每个活动调 `_gen_one_link`:美团走 `meituan.get_referral_link(act_id/product_view_sign, sid=群.sid)` 拿短链作 `target_url`(无群 sid 则 400);淘宝/京东直接用活动 `payload` 作 `target_url`。落库走 `create_link`,短码 `code` 由 `_gen_code` 生成(去易混字符,冲突重试 5 次仍冲突用 12 位兜底)。`code` 全局唯一即天然幂等键,但**每次调用都新建 link**(同群同活动重复生成会产生多条不同 code)。
|
||||
- **U(更新)**:**无**。link 一旦生成不改(改了等于换链接)。
|
||||
- **R(读)**:`GET /c/{code}`(`cps_landing`)落地时 `get_by_code` 取 link → 决定跳转/落地;`POST /c/{code}/copy` 同样按 code 取。后台无 link 列表端点(运营只看生成结果与按群统计)。
|
||||
- **D(删除)**:**无删除路径**。删群/删活动都不级联删 link(已发链接保持可用)。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | 被 `cps_click.link_id` 语义引用(非 FK) |
|
||||
| `code` | String(16) | **UNIQUE, index, NOT NULL** | 短码,发群链接 `/c/{code}` 的 code。字母表去掉 `0/O/1/l/I`(肉眼复制不易错),默认 8 位 |
|
||||
| `group_id` | Integer | index, NOT NULL | 归属群 = `cps_group.id`(语义关联,非 FK) |
|
||||
| `activity_id` | Integer | index, NOT NULL | 对应活动 = `cps_activity.id`(语义关联,非 FK);淘宝落地页反查活动取图 |
|
||||
| `sid` | String(64) | index, **nullable** | 渠道追踪位,**仅美团 link 有**(= 群 sid);淘宝/京东无 sid。生成时从群冗余进来 |
|
||||
| `platform` | String(20) | NOT NULL, default `meituan` | `meituan` / `taobao` / `jd`。决定 `cps_landing` 是 302 跳还是渲染淘宝落地页 |
|
||||
| `target_url` | String(2048) | NOT NULL | 跳转/落地目标:美团短链 / 京东链接(302 跳) / 淘宝整段淘口令文本(H5 落地页复制) |
|
||||
| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 生成时间(无 `updated_at`) |
|
||||
|
||||
## 关系 / Join Key
|
||||
- `group_id` → `cps_group.id`;`activity_id` → `cps_activity.id`;`sid` ≈ `cps_group.sid`——**全是语义关联,无 ForeignKey**(删群/删活动都不报错、不级联)。
|
||||
- `id` ← `cps_click.link_id`(一 link 多点击);记点击时 `record_click` 把 link 的 `group_id`/`sid` 冗余进 [`cps_click`](./cps_click.md)。
|
||||
- `target_url` 间接关联 [`cps_order`](./cps_order.md):美团 `target_url` 携带群 sid,用户经它下单后订单回来的 `sid` 即此群 sid——但 link 与 order **没有直接列关联**,只经 `sid` 在群维度汇合。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`;**UNIQUE+index `code`**(`uq_cps_link_code` + `ix_cps_link_code`);index `group_id`、`activity_id`、`sid`。
|
||||
- **无外键**。
|
||||
|
||||
## 注意
|
||||
- **`target_url` 从 1024 加长到 2048**:初版(`277f9b1`)只装美团短链(够短),`3a40f61` 接淘宝后要装**整段淘口令文本**(较长),迁移 `cps_v2_platforms` 把 `target_url` 放宽到 2048,同时 `sid` 改 nullable(淘宝/京东 link 无 sid)。
|
||||
- **`sid` 冗余进 link 又冗余进 click**:统计时按群聚合点击不必 `join cps_link`(见 `click_stats_by_group` 直接读 `cps_click.group_id`);`sid` 留在 link 上则是为了生成时一次性带齐,跳转无需再查群。
|
||||
- **短码去易混字符**:`_CODE_ALPHABET` 排除 `0/O/1/l/I`,因为短链会被运营/用户肉眼复制粘贴,降低抄错率;碰撞重试 5 次后降级 12 位,概率近 0。
|
||||
- **三平台落地分叉**(`cps_redirect.cps_landing`):美团/京东 → 302 跳 `target_url`;淘宝 → 不能 302(淘口令不是 URL),改返回自渲染 H5 落地页(图来自活动 `image_url`,按钮复制 `target_url` 淘口令)。code 不存在时兜底跳 `https://www.meituan.com/`。
|
||||
@@ -0,0 +1,51 @@
|
||||
# cps_order — CPS 对账订单(美团联盟 query_order 拉回的订单明细)
|
||||
|
||||
> 模型 `app/models/cps_order.py` · 仓库 `app/admin/repositories/cps.py`(`reconcile_orders` / `_map_order_fields` / `list_orders`、统计 `group_stats`) · 接口 admin `POST /admin/api/cps/orders/reconcile`、`GET /admin/api/cps/orders`、`GET /admin/api/cps/stats` · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
从美团联盟 `query_order` 按时间窗拉回、按 `sid` 归群的订单明细,是 CPS 群发漏斗的**最下游"赚了多少佣金"**:用户点 [`cps_link`](./cps_link.md)(携群 [`cps_group`](./cps_group.md) 的 `sid`)下单后,美团把订单连同 `sid` 回传,这里按 `sid` 归群做对账,与 [`cps_click`](./cps_click.md) 的点击量在 `group_stats` 汇成"点击→下单→佣金"漏斗。**仅美团有此表**——淘宝/京东无对账 API,统计里对账字段显示 `-`。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C/U(upsert)**:`POST /admin/api/cps/orders/reconcile`(`reconcile_orders`,需 `finance` 角色)。调 `meituan.query_order(sid?, start_time, end_time, page, limit=100)` 分页拉单(`max_pages=200` 防死循环),每条经 `_map_order_fields` 转字段,按 `order_id` **upsert**:不存在则 insert,存在则逐字段覆盖(订单状态会随时间变 付款→完成→结算/退款,重复拉则更新)。返回 `{fetched, inserted, updated, pages}`。`order_id` 全局唯一即幂等键。
|
||||
- **R(读)**:`GET /admin/api/cps/orders`(`list_orders`,游标分页,可按 `sid`/`mt_status` 过滤);统计 `group_stats` 拉全部订单按 `sid` 分桶,算 `order_count`/`gmv`/`est_commission`/`settled_commission` 等。
|
||||
- **D**:**无**。
|
||||
|
||||
## 字段
|
||||
> 字段对齐 `query_order` 实测返回:金额(payPrice/profit)是「元」字符串 → 入库 `_yuan_to_cents` 转「分」(全站口径);时间(payTime/updateTime)是秒级时间戳 → `_ts_to_dt` 转 tz-aware UTC。
|
||||
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join / 源字段) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `order_id` | String(64) | **UNIQUE, index, NOT NULL** | 美团订单号(加密串),`orderId`。upsert 幂等键 |
|
||||
| `sid` | String(64) | index, **nullable** | 渠道追踪位 = 群 `sid`,源 `sid`。**按它归群聚合**;历史无 sid 订单为空 |
|
||||
| `act_id` | String(64) | index, nullable | 活动物料 ID,源 `actId`(转字符串) |
|
||||
| `biz_line` | Integer | nullable | 业务线,源 `businessLine`。`1=外卖` |
|
||||
| `trade_type` | Integer | nullable | 源 `tradeType`。`1=cps 2=cpa` |
|
||||
| `pay_price_cents` | Integer | nullable | 付款金额(分),源 `payPrice`(元)。统计算 GMV |
|
||||
| `commission_cents` | Integer | nullable | **预估佣金**(分),源 `profit`(元)。统计算预估/已结算佣金 |
|
||||
| `commission_rate` | String(16) | nullable | 佣金率,源 `commissionRate`。`"300"=3%`、`"10"=0.1%`(原样字符串,前端解释) |
|
||||
| `refund_price_cents` | Integer | nullable | 退款金额(分),源 `refundPrice`(元) |
|
||||
| `refund_profit_cents` | Integer | nullable | 退款佣金(分),源 `refundProfit`(元) |
|
||||
| `mt_status` | String(8) | index, nullable | 美团订单状态,源 `status`:`2`付款 `3`完成 `4`取消 `5`风控 `6`结算 |
|
||||
| `invalid_reason` | String(128) | nullable | 失效原因,源 `invalidReason` |
|
||||
| `product_name` | String(512) | nullable | 商品名,源 `productName`(超 500 截断) |
|
||||
| `pay_time` | DateTime(tz) | index, nullable | 付款时间,源 `payTime`(秒级 ts)。统计按时间窗过滤的就是它 |
|
||||
| `mt_update_time` | DateTime(tz) | nullable | 美团侧更新时间,源 `updateTime`(秒级 ts) |
|
||||
| `raw` | JSON / JSONB | NOT NULL, default `{}` | `query_order` 单条原始 dataList,留底排查/补字段 |
|
||||
| `first_seen` | DateTime(tz) | server_default now(), NOT NULL | 首次拉到入库时间(本地) |
|
||||
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 最近 upsert 时间(本地) |
|
||||
|
||||
## 关系 / Join Key
|
||||
- `sid` ≈ `cps_group.sid`(订单按 sid 归群,**语义关联,无 ForeignKey**)。`group_stats` 即据此把订单挂到群行。
|
||||
- 与 [`cps_link`](./cps_link.md)/[`cps_click`](./cps_click.md) **无直接列关联**:下单链路是"美团短链(带 sid)→ 美团 App 下单",订单只回传 `sid`、不回传我们的 `link.code` 或某次点击 id。所以点击与订单只能在**群(sid)维度**汇合,**无法把单笔订单对到单次点击**。
|
||||
- **本表无 `user_id`**:CPS 联盟订单是别人(被推广的微信群用户)在美团下的单,与本 App 的 `user` 表无关,纯渠道佣金对账,不归属本 App 用户。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`;**UNIQUE+index `order_id`**(`uq_cps_order_order_id` + `ix_cps_order_order_id`);index `sid`、`act_id`、`mt_status`、`pay_time`。
|
||||
- **无外键**。
|
||||
|
||||
## 注意
|
||||
- **状态决定佣金口径**(`group_stats._order_agg`):`4`取消/`5`风控**不计佣金**(`_INVALID_STATUS`);`6`结算才是佣金**真正到账**(`_SETTLED_STATUS`)。统计区分 `order_count`(有效=非取消非风控)、`settled_count`(已结算)、`canceled_count`,佣金分 `est_commission`(有效单预估)与 `settled_commission`(结算单)。
|
||||
- **"未归群"独立行**:`group_stats` 遍历完所有群后,对剩下的、有订单但 `sid` 不属任何现存群的 sid(历史遗留如 `wonderableai`、或别处来源、或群被删),单列一行 `group_id=None`、有对账无点击。所以本表 `sid` 可空/可孤立是设计内的,不是脏数据。
|
||||
- **金额/时间统一转换的原因**:`query_order` 返回金额是「元」字符串、时间是秒级 ts;`_yuan_to_cents`(Decimal 防浮点,`"null"`/空 → None)与 `_ts_to_dt`(转 tz-aware UTC,前端按北京展示)在入库时归一,与全站"金额存分、时间存 tz-aware"口径对齐。`raw` 整条留底,字段不够时不用重拉。
|
||||
- **upsert 而非 append**:同一订单会被多次拉到(状态变化),按 `order_id` 覆盖即可拿到最新状态;`reconcile` 可重复跑(幂等)。
|
||||
- **本表自始至终只服务美团**:初版(`277f9b1`)就定型,`3a40f61` 接淘宝/京东时**没动本表**——那俩平台无对账 API,统计里对账列直接给 `None`(前端显示 `-`)。这是"对账 = 美团专属"的边界。
|
||||
@@ -0,0 +1,39 @@
|
||||
# invite_fingerprint — 邀请指纹归因(剪贴板兜底)
|
||||
|
||||
> 模型 `app/models/invite_fingerprint.py`(`InviteFingerprint`) · 仓库 `app/repositories/invite.py` · 接口 `app/api/v1/invite.py`:`POST /api/v1/invite/landing-track`(写,无鉴权)、`POST /api/v1/invite/bind`(读,指纹反查分支) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
一行 = 一次落地页访问的设备指纹快照。**剪贴板归因的兜底**:剪贴板 deferred-deeplink 是邀请归因主路径,但从"点下载"到"App 首启"之间剪贴板可能被覆盖、归因丢失。此表在落地页访问时先把 `(inviter, ip, screen, device_model, ua)` 记下来,被邀请人登录后剪贴板没拿到邀请码时,用 `(ip, device_model)` 反查 7 天内最近一条匹配 → 拿出邀请人 → 走 `bind(channel='fingerprint')`。任务来源见 #31 好友邀请任务 3。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C(插入)**:`POST /api/v1/invite/landing-track`(`landing_track`,**无需鉴权**,浏览器没 token)→ `invite_repo.record_fingerprint()`。B 浏览器打开 `dl.html?ref=<码>` 时 JS POST,服务端从 HTTP 头拿 `ip`(`_client_ip`,优先 `X-Forwarded-For` 第一段)、解析 UA 拿 `device_model`(`_parse_device_model`,Android 正则抓 `;...Build/` 前 token = `Build.MODEL`),连同 JS 上报的 `screen` 一起入库。`record_fingerprint` 自行 commit(调用方此前无其它写)。无效码 / 拿不到 IP 走 silent 返回(`invalid_code`/`no_ip`,不抛 5xx 影响下载流程)。
|
||||
- **R(反查)**:`POST /api/v1/invite/bind`(`bind_invite`)的指纹分支 → `resolve_inviter_by_fingerprint()`。仅在请求**无邀请码、有 `fingerprint`** 时触发:`WHERE ip=? AND device_model=? AND created_at > now - INVITE_FP_WINDOW_DAYS(7d)` `ORDER BY created_at DESC LIMIT 1` → 命中则取 `inviter_user_id` 对应用户 → 用其 `invite_code` 走原 `bind()`(同样过幂等/自邀/新人闸/发币逻辑);未命中返回 `fp_not_found`。
|
||||
- **U / D**:无。只增不改不删。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `inviter_user_id` | Integer | FK→user.id, index, NOT NULL | 落地页对应的邀请人(由 `?ref=<码>` 解析得到) |
|
||||
| `ip` | String(64) | NOT NULL | 访问者 IP(服务端从 HTTP 头拿;nginx 反代时走 `X-Forwarded-For`)。入库 `[:64]`。**反查参与匹配** |
|
||||
| `device_model` | String(64) | NOT NULL, default `""` | 设备型号(如 `PJF110`、`23046PNC9C`)。浏览器侧由 UA 正则解析,客户端侧 = `Build.MODEL`,跨端对齐。入库 `[:64]`。**反查参与匹配** |
|
||||
| `screen` | String(32) | NOT NULL, default `""` | 屏幕分辨率 `"1080x2400"`(浏览器 `screen.w×h`,客户端 `DisplayMetrics`)。入库 `[:32]`。**只入库不反查**(见注意) |
|
||||
| `user_agent` | Text | NOT NULL, default `""` | 完整 UA 串(debug / 排查"匹配不上"用,不参与匹配)。入库 `[:2000]` 防异常长 UA |
|
||||
| `created_at` | DateTime(tz) | server_default now(), index | 落地页访问时间(= 7 天窗口判据 + 反查倒序键) |
|
||||
|
||||
## 关系 / Join Key
|
||||
- `inviter_user_id` → `user.id`(硬 FK,多对一):一个邀请人的落地页可被多次访问。
|
||||
- **反查组合键 = `(ip, device_model)` + `created_at > cutoff`**:等值匹配 IP 与型号、再取窗口内最近一条。注意 `screen` 虽进了组合索引最左前缀,但 SQL 不拿它做 WHERE 条件。
|
||||
- 反查结果不直接 join 本表→`user`,而是 `db.get(User, fp.inviter_user_id)` 拿邀请人,再回 `invite_relation` 走 `bind()`。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`。
|
||||
- `ix_invite_fp_match`(`ip, screen, device_model, created_at`):反查主索引。`/bind` 兜底分支按 `ip + device_model + created_at` 范围扫 + 倒序 LIMIT 1,SQLite/PG 优化器都能用上该复合索引。
|
||||
- `ix_invite_fp_inviter`(`inviter_user_id`):统计/排查用("这个邀请人的落地页被谁访问过")。
|
||||
|
||||
## 注意
|
||||
- **`screen` 只入库不反查(关键设计,`resolve_inviter_by_fingerprint` docstring)**:浏览器算物理像素走 `CSS × devicePixelRatio`、Android 走 `DisplayMetrics.widthPixels` 真实硬件值,两端浮点 round 必然 ±1 像素漂移(DPR 不严格是整数)→ 严格匹配注定撞不上。而**同 `device_model` 必同 `screen`**(同型号同硬件),故 `screen` 是冗余字段,删它不损精度。所以匹配只用 `(ip, device_model)`。
|
||||
- **组合索引为何含 `screen`**:索引列序是 `(ip, screen, device_model, created_at)`,但 WHERE 只用 `ip`/`device_model`/`created_at`。`screen` 夹在中间会打断"`ip` 之后直接接 `device_model`"的最左连续前缀,理论上略损此查询的索引效率(待确认:实测扫描量是否受影响;当前数据量下无感)。若后续优化,建议把索引调成 `(ip, device_model, created_at)`。
|
||||
- **7 天窗口(`rewards.INVITE_FP_WINDOW_DAYS`)的取舍**:太短(24h)覆盖不到"晚上看链接、第二天装";太宽(30d)IP/设备会漂、匹配错率上升;7 天兼顾"看广告→使用"周期与匹配精度。
|
||||
- **撞错概率**:只有"同一 IP 下、同型号手机、7 天内同时被不同人邀请"才会归因到错的邀请人,中国家庭/公司 WiFi 场景概率极低;属可接受的工程取舍而非 bug。
|
||||
- **与 invite_relation 的关系**:本表只是**线索库**,不发奖、不是结果。真正落地是反查出邀请人后回到 [invite_relation](./invite_relation.md) 的 `bind()`,写一行 `channel='fingerprint'` 并发币;本表本身不参与发奖与幂等。
|
||||
- **迁移**:`invite_fingerprint_table`(`down_revision=0cf18d590b1d`),晚于 `invite_code_and_relation`(#24 的表先建)。
|
||||
@@ -0,0 +1,46 @@
|
||||
# invite_relation — 好友邀请绑定关系(注册即生效)
|
||||
|
||||
> 模型 `app/models/invite.py`(`InviteRelation`) · 仓库 `app/repositories/invite.py` · 接口 `app/api/v1/invite.py`:`POST /api/v1/invite/bind`(写)、`GET /api/v1/invite/me`(战绩)、`GET /api/v1/invite/invitees`(列表) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
一行 = 一次**成功的**邀请绑定。被邀请人(B)用邀请人(A)的邀请码完成绑定后写入,**注册即生效**:同一事务里给 A、B 各发 1 万金币(= 1 元,可提现)。数据来自 `bind()`,触发它的归因来源有三种(`channel`)。这是邀请功能的**结果表 / 账本**;邀请码本身存在 `user.invite_code`,不在这张表。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C(插入)**:`POST /api/v1/invite/bind`(`bind_invite`)→ `invite_repo.bind()`。过四道防线后建一行 `status='effective'`,**同事务**复用 `wallet.grant_coins` 给双方发金币(`grant_coins` 只 flush,由 `bind()` 统一 commit)→ "建关系 + 双方加金币"原子。
|
||||
- **幂等键 = `invitee_user_id` 唯一**:一个 B 只能被绑一次,重复请求返回 `already_bound`,不重复发奖(仿 `ad_reward_record.trans_id` 思路)。并发下两请求同时插同一 invitee → 后者撞唯一约束 `IntegrityError`,`bind()` rollback 后改判 `already_bound` 兜底。
|
||||
- 四道防线(`bind()` 内):① invitee 已绑 → `already_bound`;② 无效码 / 邀请人非 `active` → `invalid_code`;③ 自邀(inviter==invitee)→ `self_invite`;④ 新人闸 `_is_new_user`(B 的 `created_at` 在 `rewards.INVITE_NEW_USER_WINDOW_HOURS`=72h 内)→ 否则 `not_eligible`。只有全过才插行 + 发奖。
|
||||
- **U / D**:无。这张表只增不改不删(纯账本)。
|
||||
- **R**:
|
||||
- `GET /api/v1/invite/me`(`my_invite`)→ `get_stats(inviter_id)`:`count(*)` 得已邀人数、`sum(inviter_coin)` 得累计金币。
|
||||
- `GET /api/v1/invite/invitees`(`my_invitees`)→ `get_invitees(inviter_id, limit, offset)`:join `user` 出被邀请人列表(倒序分页),名字降级兜底 `nickname → wechat_nickname → 脱敏手机号`,`coins` 取 `inviter_coin`。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `inviter_user_id` | Integer | FK→user.id, index, NOT NULL | 邀请人(A) |
|
||||
| `invitee_user_id` | Integer | FK→user.id, **UNIQUE**, index, NOT NULL | 被邀请人(B)。**唯一 = 幂等键**,一个 B 只能被归因一次 |
|
||||
| `channel` | String(16) | NOT NULL, default `clipboard` | 归因来源:`clipboard`(剪贴板自动)/ `manual`(手动填码)/ `fingerprint`(指纹反查兜底)。入库前 `[:16]` 截断 |
|
||||
| `status` | String(16) | NOT NULL, default `effective` | 当前只有 `effective`(注册即生效)。**预留** `pending`/`effective`:将来若改"完成首单才生效"时启用 |
|
||||
| `inviter_coin` | Integer | NOT NULL, default 0 | 本次给 A 发的金币(记账留痕,=`rewards.INVITE_INVITER_COINS`=10000) |
|
||||
| `invitee_coin` | Integer | NOT NULL, default 0 | 本次给 B 发的金币(=`rewards.INVITE_INVITEE_COINS`=10000) |
|
||||
| `created_at` | DateTime(tz) | server_default now(), index | 绑定时间(= 列表倒序键) |
|
||||
|
||||
## 关系 / Join Key
|
||||
- `inviter_user_id` → `user.id`(硬 FK,多对一):一个 A 可邀多个 B。
|
||||
- `invitee_user_id` → `user.id`(硬 FK,**一对一**,唯一约束):一个 B 至多一行。
|
||||
- 发金币时落 `coin_transaction`:`biz_type='invite_inviter'`(给 A,`ref_id=invitee.id`)/ `biz_type='invite_invitee'`(给 B,`ref_id=inviter.id`),双方 `ref_id` 互指对方便于对账。
|
||||
- `get_invitees` 用 `InviteRelation JOIN user ON user.id = invitee_user_id` 取被邀请人资料;`total` 单独 `count` 算 `has_more`。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`。
|
||||
- `ix_invite_relation_inviter_user_id`(`inviter_user_id`):`get_stats` / `get_invitees` 按邀请人查。
|
||||
- `ix_invite_relation_invitee_user_id`(`invitee_user_id`,**UNIQUE**):幂等键 + 加速 `_relation_of_invitee` 反查。
|
||||
- `ix_invite_relation_created_at`(`created_at`):列表倒序。
|
||||
|
||||
## 注意
|
||||
- **防重复发奖三道**(`repositories/invite.py` docstring):① `invitee_user_id` 唯一(应用层 `_relation_of_invitee` 先查 + DB 唯一约束并发兜底);② 自邀屏蔽;③ 手机号天然唯一(每个 B = 一个真实手机号账号)= 限制刷量规模。
|
||||
- **`status` 当前恒为 `effective`**:产品取"注册即生效"而非"完成首单才生效",`pending` 取值是为后者预留、目前不写入。
|
||||
- **`inviter_coin`/`invitee_coin` 是留痕字段**:写死当时发的金币值,即便日后改奖励常量,历史行仍保留发奖时的额度,便于对账。
|
||||
- **`channel` 三种取值**:`clipboard`(deferred-deeplink 主路径,落地页写剪贴板、首启读回)/ `manual`(用户在邀请页手输)/ `fingerprint`(剪贴板被覆盖时走指纹兜底,见 [invite_fingerprint](./invite_fingerprint.md));三者都汇入同一个 `bind()`,只 `channel` 不同。
|
||||
- **风控缺口(#24 设计文档「上线前还差什么」标注)**:1 万金币可提现且**邀请人无总数上限**,接码平台批量注册新号绑同码即可刷;上线前需加 inviter 上限 + 基础风控。当前 MVP 仅靠"手机号唯一 + 72h 新人闸"挡。
|
||||
- **alembic 多 head**:本表迁移 `invite_code_and_relation`(`down_revision=11a1d08c6f55`)与 `coupon_state_tables` 是同一父的兄弟迁移,合 main 前需建 merge 迁移,否则 prod `alembic upgrade head` 撞 `Multiple head revisions`(#24 设计文档已警示)。
|
||||
@@ -0,0 +1,71 @@
|
||||
# price_observation — 比价沉淀的价格事实(平台/门店视角的价格资产层)
|
||||
|
||||
> 模型 `app/models/price_observation.py` · 仓库 `app/repositories/price_observation.py` · 上报端点 `app/api/internal/price.py`(`POST /internal/price-observation`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
每完成一次比价,pricebot 在 done 帧把「在某平台某店、这一篮菜/这单到手价多少钱」按平台逐条算出来,**server→server 内部上报**落这里(一次外卖比价 = 源平台 1 条 + N 个目标平台各 1 条,`scope='order'`)。**与登录无关、不依赖客户端鉴权**——比价透传链路(`compare.py`)当前不鉴权,只要比价跑到 done 就记,**匿名用户也记**(来源记 `source_device_id`,`source_user_id` 客户端带上时才有)。
|
||||
|
||||
与 [`comparison_record`](./comparison_record.md) 的区别(model docstring 明示,两表独立、互不影响):
|
||||
- `comparison_record` = **用户视角**的「我的比价记录」,客户端登录后用带 JWT 的通道(`POST /compare/record`)主动上报,按 `(user_id, trace_id)` 存。
|
||||
- `price_observation` = **平台/门店视角的客观价格事实**,server 侧**无条件沉淀**,按 (平台, 门店, 菜, 时间) 组织。是未来「别人查过同店 → 直接秒回价格」价格大数据的源头(#21,「先存数据、用法后续」)。
|
||||
|
||||
> raw trace(逐 step 全过程)另存 pricebot 本地 `jsonl.gz`,本表只存**提炼后的价格事实**(本表可从 raw 重算);`trace_id` 软指 pricebot work_logs 供溯源/重算。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C(批量插入,幂等)**:`POST /internal/price-observation`(`report_price_observation` → `repo.insert_batch`)。pricebot 在 done 帧把整批观测 POST 过来落库。幂等键 = `(trace_id, platform, scope)`:仓库**先查该 trace 已有的 `(platform, scope)`,只插新的**(不依赖 `ON CONFLICT`,跨方言 PG/SQLite 都安全),并发撞唯一约束则 `db.rollback()` 后整批跳过(本批本就幂等,**不重试**,见 P0 约定)。同批内还会去重一次(防 pricebot 同帧给重复平台)。返回 `{inserted, skipped}`。
|
||||
- **U / D**:无。append-only,无更新无删除。
|
||||
- **R**:**当前无读取端点**(纯写入沉淀层,未来做「秒回价格」/聚合时再用)。
|
||||
|
||||
> 上报端点是 **server→server 专用**:不走用户 JWT,靠共享密钥头 `X-Internal-Secret`(== `settings.INTERNAL_API_SECRET`,常量时间比较 `hmac.compare_digest`)。**密钥未配置(默认空)→ 直接 503**(挡住裸奔的写端点);不匹配 → 401。该密钥须与 pricebot 侧同值。
|
||||
|
||||
## 字段
|
||||
|
||||
批级字段(门店/菜篮/地理/来源,一次比价共享,由 `insert_batch` 灌进每条)vs 逐平台字段(`observations[]` 各自带)见下表「来源」列。
|
||||
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `observed_at` | DateTime(tz) | server_default now(), index, NOT NULL | 观测时刻(≈比价 done 时;服务端落库时取 now)。按时间查「最近有效价」用 |
|
||||
| `trace_id` | String(64) | index, NOT NULL | pricebot 侧 trace_id:**幂等去重键**(含于唯一约束)+ 回指 A 层原始 trace(溯源/重算)。软指 pricebot work_logs,无硬 FK |
|
||||
| `business_type` | String(16) | NOT NULL, default `food`, index | `food`(外卖,当前唯一接通)/ `ecom`(电商,二期)。**批级共享** |
|
||||
| **门店维度** | | | denormalize 成文本,不建维度表;实体归一二期再做 |
|
||||
| `platform` | String(32) | index, NOT NULL | 平台标识。**逐平台**(`observations[].platform`),含于唯一约束 |
|
||||
| `platform_store_id` | String(64) | 可空 | 平台内门店 ID(抓得到才存,将来归一最稳的 key;**现在多为空**)。**逐平台** |
|
||||
| `store_name` | String(128) | index, 可空 | 门店名。**批级共享**(源/目标同名场景下整批一份) |
|
||||
| `city` | String(64) | 可空 | **批级共享** |
|
||||
| `geohash` | String(16) | index, 可空 | 地理(**现在拿不到→留空**;二期把 device 经纬度透传进比价请求后回填)。**批级共享** |
|
||||
| `lng` / `lat` | Float | 可空 | 同上,**批级共享** |
|
||||
| **价格事实** | | | |
|
||||
| `is_source` | Boolean | NOT NULL, default false | 是否源平台(发起比价那家)。源平台价也是真实观测,照记。**逐平台** |
|
||||
| `scope` | String(16) | NOT NULL, default `order` | 口径:`order`(整单到手价,外卖当前唯一)/ `dish`(单菜单价,引擎暂不逐菜读,二期)。含于唯一约束。**逐平台** |
|
||||
| `price_cents` | Integer | 可空 | 该口径到手价(分);**None = 该平台采集失败 / 门店打烊,无有效价**。**逐平台** |
|
||||
| `coupon_saved_cents` | Integer | 可空 | 红包/券省了多少(分)。**逐平台** |
|
||||
| `coupon_name` | String(64) | 可空 | 优惠来源名。**逐平台** |
|
||||
| `store_closed` | String(32) | 可空 | 目标店打烊原因(**非空时 `price_cents` 为 None**)。**逐平台** |
|
||||
| `rank` | Integer | 可空 | 本次比价全局名次(1=最便宜;按到手价升序,源/目标统一排)。**逐平台** |
|
||||
| **明细(JSON)** | | | |
|
||||
| `dishes` | JSON(PG JSONB) | 可空 | 菜篮 `[{name, qty, subtotal?}]`(外卖)。dish 级单价二期从这里 + 引擎增强推。**批级共享** |
|
||||
| `attrs` | JSON(PG JSONB) | 可空 | 灵活字段兜底(配送费/起送/活动名/跳过菜数 等),免得加字段就迁移。**逐平台**(`observations[].attrs`) |
|
||||
| **来源(溯源/去重/置信)** | | | |
|
||||
| `source_device_id` | String(64) | index, 可空 | 来源设备;匿名比价也有。**批级共享** |
|
||||
| `source_user_id` | Integer | index, 可空 | **比价链路不鉴权拿不到,客户端带上时才有**,先可空、不进唯一键。软指 `user.id`。**批级共享** |
|
||||
| `confidence` | Float | NOT NULL, default 1.0 | 置信度,预留 |
|
||||
| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 落库时间 |
|
||||
|
||||
## 关系 / Join Key
|
||||
- **无硬外键**。
|
||||
- `trace_id` **软指** pricebot work_logs(溯源/重算/排查),并作幂等去重键;与 `comparison_record.trace_id` 同源(同一次比价的 pricebot trace),但两表不互相 join、各存各的视角。
|
||||
- `source_user_id` **软指** `user.id`(可空,不进唯一键、不阻塞落库)。
|
||||
- `source_device_id` 与不鉴权期 `comparison_record.device_id` / 领券三表 `device_id` 同源,可用于对账(无 SQL 级约束)。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`。
|
||||
- **UNIQUE(`trace_id`, `platform`, `scope`) = `uq_price_obs_trace_platform_scope`**:一次比价(trace)里同一平台、同一口径只一条 → pricebot 重试 / 客户端 replay 重复上报幂等去重。
|
||||
- index:`observed_at`、`trace_id`、`business_type`、`platform`、`store_name`、`geohash`、`source_device_id`、`source_user_id`(为将来按 平台/门店/菜/时间/地理 查询聚合预埋)。
|
||||
|
||||
## 注意
|
||||
- **资产层定位「先存后用」**:当前**只写不读**(无 R 端点、不参与任何业务判断),是为「别人查过同店→秒回价格」大数据预先沉淀的原始事实。结构化列预埋查询/聚合维度,用法二期再定。
|
||||
- **`attrs` / `dishes` 是 JSONB 兜底,别塞原始无障碍树**(几十 KB → 行膨胀);原始大树/逐 step 看 `trace_id` 指过去的 pricebot 落盘。`_JSON = JSON().with_variant(JSONB(), "postgresql")`:PG 用 JSONB(可建 GIN),SQLite dev 退化通用 JSON(同 `comparison_record` / `coupon_claim_record`)。
|
||||
- **`price_cents=None` 是有意义的观测**:表示该平台采集失败 / 门店打烊(看 `store_closed`),不是「没记」。统计「最低价」时要先过滤 None。
|
||||
- **当前数据多为半空**:`platform_store_id` 多为空(门店 ID 抓不全)、`geohash/lng/lat` 留空(device 经纬度二期才透传进比价请求)、`source_user_id` 多为空(链路不鉴权)。这些是**二期回填**计划,不是 bug。
|
||||
- **幂等/并发**:不重试、失败就失败(P0 约定);同批去重 + 唯一键 + `IntegrityError` 回滚三重防御,但回滚是**整批**回滚(撞约束则本批全跳过)。
|
||||
- 上报端点未配 `INTERNAL_API_SECRET` 时返 **503**(不是 401),这是「内部端点默认关闭」的安全默认态——本地 dev / 未启用时该表不会有新数据。
|
||||
@@ -0,0 +1,85 @@
|
||||
# store_mapping — 跨平台「同一家店」的 id/名/deeplink 身份映射(门店资产层)
|
||||
|
||||
> 模型 `app/models/store_mapping.py` · 仓库 `app/repositories/store_mapping.py` · 上报/反查端点 `app/api/internal/store.py`(`POST /internal/store-mapping`、`GET /internal/store-mapping/lookup`、`POST /internal/store-mapping/invalidate`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
每完成一次比价,pricebot 把同一家物理店在 **淘宝/美团/京东** 各自的店铺 id、店名、可复跳 deeplink **server→server 内部上报**落这里一行(一次比价 = 一个 trace = 一行,各平台「腿」解析出 id 后填空合并进同一行)。**与登录无关、不依赖客户端鉴权**(同 [`price_observation`](./price_observation.md),`source_user_id` 客户端带上时才有,匿名也记)。
|
||||
|
||||
与 [`price_observation`](./price_observation.md) 的区别(两表平行、独立):
|
||||
- `price_observation` = 平台/门店视角的**价格事实**(某店这单多少钱)。
|
||||
- `store_mapping` = 平台/门店视角的**身份映射**(同一家店在各平台的店铺 id/名/deeplink)。是未来「我见过这家店 → 跳过重新搜索/匹配,直接 deeplink 进店内搜索」的源头(#48)。
|
||||
|
||||
与 [`comparison_record`](./comparison_record.md)(用户视角、带 JWT、按 `user_id` 存)也无关:本表是 server 无条件沉淀的客观映射、匿名也记。
|
||||
|
||||
> **⚠️ 数据质量**:跨平台「同一家店」的连接来自 agent 的 **LLM 店铺匹配**,匹配错则一行里 `id_taobao` 与 `id_jd`/`name_meituan` **连到不同店**。本表是 **append-only 原始记录**(每比价一行、`trace_id` 幂等防重试重复),「一行里多平台 id 共存」只表示「两条腿搜同一个源店名各自匹配到了某家店」= name-match 置信度、**非已核实同一实体**;清洗/归一/精确匹配二期由下游做。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C / U(upsert,trace_id 维度幂等 + 填空合并)**:`POST /internal/store-mapping`(`report_store_mapping` → `repo.upsert`)。合并键 = `trace_id`(唯一约束)。一次跨平台比价 = 一个 trace = 一个真实店铺:某条腿解析出 id 先建行(填自己那几列),后到的腿(将来)再 upsert 进**同一行**。**合并策略 = 填空(fill-the-blanks)**:`_merge_fill_blanks` 只写该行当前为 NULL 的列、**绝不覆盖已有非空值**;共享列(geo/source/溯源)先到先得。首写用逐列 `setattr`(而非硬编码构造器,否则首写是美团/京东腿时其列会漏)。不依赖 `ON CONFLICT`,跨方言安全;并发撞唯一约束则 `rollback()` 后转填空合并路径。返回 `{inserted, row_id}`(inserted=1 新建 / 0 合并)。
|
||||
- **R(lookup 反查,命中即省一整段现场反查)**:`GET /internal/store-mapping/lookup?source_platform=&name=&lat=&lng=`(`lookup_nearest`)。**比价前**按「源平台店名」反查各目标平台已沉淀的店铺 id,命中就让 pricebot **直接 deeplink 跳店内搜索**,省掉「开平台→进店→分享→反查」整段。机制:匹配键 = 源平台对应的 name 列精确等于 `name`;每个目标**分别**取「含该目标 id 的同名候选里最近一条」(有入参 geo 且候选带 geo → haversine 取最近,否则取 `created_at` 最新);**排除源平台自己**(不复用源平台 id);**过滤掉 deeplink 已失效的候选**(见下)。返回 `{target_key: {id..., deeplink, row_id, dist_km?}}`,空 = 没缓存、pricebot 走现场反查老路。
|
||||
- **U(deeplink 失效标记)**:`POST /internal/store-mapping/invalidate`(`{platform, shop_id}`)。pricebot 撞错误页回退时上报,按 `shop_id` 把**所有**匹配行的对应 `*_deeplink_invalid_at` 置 `now()`(幂等:已标记的跳过)。`taobao`→`mark_taobao_deeplink_invalid`、`jd`→`mark_jd_deeplink_invalid`;**其它平台(含 meituan)当前 no-op 返回 `affected=0`、不报错**(向后兼容 pricebot 将来扩展)。返回 `{ok, affected}`。
|
||||
- **D**:无业务删除。
|
||||
|
||||
> 上报/反查/失效端点全是 **server→server 专用**,复用 `price.py` 的 `_check_secret`(同一 `X-Internal-Secret` 密钥,未配置→503,不匹配→401)。
|
||||
|
||||
## 字段
|
||||
|
||||
各平台列**按比价角色稀疏填充**(淘宝当目标才有 `*_taobao`,依此类推)。
|
||||
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| **跨平台身份** | | | 同一物理店在各平台的 id/名 |
|
||||
| `id_taobao` | String(64) | index, 可空 | 淘宝 = 分享短链解析出的 **shopId**(淘宝当目标、走完取 id 流程才有)。门店稳定数字主键 |
|
||||
| `name_taobao` | String(128) | 可空 | 店铺页 a11y `content_desc`「店铺标题:xxx」剥前缀 |
|
||||
| `id_meituan` | String(64) | index, 可空 | **留给将来 CPS API 的稳定数字 poi_id**;当前无 share→稳定 id 机制 → 多为空(可复跳票据见 `meituan_poi_id_str`) |
|
||||
| `name_meituan` | String(128) | 可空 | 源平台店名(来自 intent/agent 匹配名) |
|
||||
| `id_jd` | String(64) | index, 可空 | 京东 = **storeId**(门店稳定数字主键,同 taobao shopId;3.cn 短链反查)。`venderId` 另存 `jd_vender_id` |
|
||||
| `name_jd` | String(128) | 可空 | 京东店铺页店名 |
|
||||
| **地理** | | | 同名店异地区分 / 地理分桶匹配主要燃料 |
|
||||
| `city` | String(64) | 可空 | |
|
||||
| `geohash` | String(16) | index, 可空 | |
|
||||
| `lng` / `lat` | Float | 可空 | lookup 时按入参 geo 取最近候选用 |
|
||||
| `taobao_address` | String(256) | 可空 | 淘宝门店地址(店铺页 a11y 抓到才有;比经纬度更利于人工/LLM 匹配) |
|
||||
| **溯源 / 画像** | | | |
|
||||
| `source_platform` | String(32) | index, 可空 | 发起比价那家:`meituan` / `taobao_flash` / `jd_waimai` … lookup 据此选 name 列、排除源平台自己 |
|
||||
| `business_type` | String(16) | NOT NULL, default `food` | |
|
||||
| `trace_id` | String(64) | NOT NULL | **幂等合并键**(唯一约束)+ 回指原始 trace(溯源)。软指 pricebot work_logs,无硬 FK |
|
||||
| `source_device_id` | String(64) | index, 可空 | |
|
||||
| `source_user_id` | Integer | index, 可空 | **比价链路不鉴权拿不到,客户端带上时才有**,软指 `user.id` |
|
||||
| **淘宝原料**(可复跳/重解析/调试) | | | |
|
||||
| `taobao_share_url` | String(256) | 可空 | `m.tb.cn` 短链 |
|
||||
| `taobao_resolved_url` | Text | 可空 | 解析出的目标 URL(含 shopId)。Text 因 URL 可能很长 |
|
||||
| `taobao_deeplink` | Text | 可空 | 拼好的 et-store/search deeplink |
|
||||
| `taobao_deeplink_invalid_at` | DateTime(tz) | 可空 | **淘宝 deeplink 失效标记**(#53)。撞「页面出错了」降级页时被置;**NULL=有效**。lookup 反查过滤掉非 NULL 的淘宝候选 |
|
||||
| **美团原料**(dpurl.cn 短链 → 302 反查 → imeituan://) | | | |
|
||||
| `meituan_poi_id_str` | String(64) | index, 可空 | **⚠️ 每次分享重新加密、非稳定主键**(调研 §八)→ 单列存「可复跳的一次性票据」,**不进 `id_meituan`** |
|
||||
| `meituan_share_url` | String(256) | 可空 | `dpurl.cn` 短链 |
|
||||
| `meituan_resolved_url` | Text | 可空 | 302 落地 menu URL(含 poi_id_str) |
|
||||
| `meituan_deeplink` | Text | 可空 | 拼好的 `imeituan://` 店内搜索 deeplink |
|
||||
| **京东原料**(3.cn 短链 → 重定向反查 → openapp.jdmobile://) | | | |
|
||||
| `jd_vender_id` | String(64) | index, 可空 | venderId 单列存(deeplink 模板 venderId+storeId 都要;venderId 是商家维度、可跨门店,与门店 storeId 分开记) |
|
||||
| `jd_share_url` | String(256) | 可空 | `3.cn` 短链 |
|
||||
| `jd_resolved_url` | Text | 可空 | 反查出的目标 `openapp.jdmobile://` URL |
|
||||
| `jd_deeplink` | Text | 可空 | 拼好的 pages/search 店内搜索 deeplink |
|
||||
| `jd_deeplink_invalid_at` | DateTime(tz) | 可空 | **京东 deeplink 失效标记**(#56,对标淘宝)。撞「当前门店超出配送范围」页时被置;**NULL=有效**。lookup 过滤非 NULL 的京东候选 |
|
||||
| `attrs` | JSON(PG JSONB) | 可空 | 灵活字段兜底,免得加字段就迁移 |
|
||||
| `created_at` | DateTime(tz) | server_default now(), index, NOT NULL | lookup 同名候选无 geo 时按它取最新 |
|
||||
|
||||
## 关系 / Join Key
|
||||
- **无硬外键**。
|
||||
- `trace_id` **软指** pricebot work_logs(溯源/排查),并作 upsert 幂等合并键。
|
||||
- `source_user_id` **软指** `user.id`(可空,不进唯一键、不阻塞落库)。
|
||||
- lookup 的匹配靠**店名字符串精确相等**(`_SOURCE_NAME_COLUMN` 把源平台映到对应 name 列)+ 可选 geo 取最近,**非 id 级 join**。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`。
|
||||
- **UNIQUE(`trace_id`) = `uq_store_mapping_trace`**:一次比价(trace)只一行 → pricebot 重试 / 客户端 replay 重复上报幂等去重;后到的平台腿填空合并进同一行。
|
||||
- index:`id_taobao`、`id_meituan`、`id_jd`、`meituan_poi_id_str`、`jd_vender_id`、`geohash`、`source_platform`、`source_device_id`、`source_user_id`、`created_at`(lookup 反查 / 失效标记按 id 列查需要)。
|
||||
|
||||
## 注意
|
||||
- **deeplink 失效→标记→回退闭环**(#53 淘宝 / #56 京东,本表最关键机制):缓存的 deeplink 会过期(淘宝撞「页面出错了」、京东撞「当前门店超出配送范围」)。pricebot 撞到错误降级页 → 调 `/internal/store-mapping/invalidate` 按 shopId/storeId 把**所有**相关行打上 `*_deeplink_invalid_at` → 后续 `lookup_nearest` **整条排除**失效候选(当没缓存,pricebot 走正常搜店)→ 重搜成功会写**新行**自然覆盖。按 shopId 标记所有行(不是单行)是因为同一坏 id 可能散在多次比价的多行里,全标掉才能让 lookup 不再返回它。
|
||||
- **美团没有失效标记**:`meituan_poi_id_str` 是「每次分享重新加密」的一次性票据、本就不稳定不复用为稳定 id,故无 invalidate 路径(`invalidate` 端点对 meituan no-op)。三平台 share→id 反查机制不对称:淘宝 `m.tb.cn`→shopId(稳定)、京东 `3.cn`→venderId+storeId(稳定)、美团 `dpurl.cn`→poi_id_str(**非稳定**)。
|
||||
- **资产层「先存后用」+ append-only**:lookup 是已接通的读用途(省现场反查),但表本身是原始记录、不做清洗归一。「一行多平台 id 共存」**不代表已核实同一实体**——下游做精确匹配前别当真值用。
|
||||
- **数据质量依赖 LLM 匹配**:跨平台连接来自 agent 店铺匹配,**可能连错店**;排查某行可疑映射时回 `trace_id` 指的 pricebot trace 看当时的匹配上下文。
|
||||
- **`attrs` 是 JSONB 兜底,别塞大树**(同 `price_observation`);`_JSON = JSON().with_variant(JSONB(), "postgresql")`,PG JSONB / SQLite dev 通用 JSON。
|
||||
- **填空合并不覆盖**:共享列(geo/source/device)先到先得,后到的腿只填自己那几列的空位;想「纠正」已写错的列**当前没有路径**(append-only,靠新 trace 写新行)。
|
||||
- 上报端点未配 `INTERNAL_API_SECRET` 时返 **503**(默认关闭态),本地 dev / 未启用时该表不会有新数据。
|
||||
@@ -315,127 +315,6 @@ 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)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""OTA 检查更新端点测试。
|
||||
|
||||
覆盖:公开读端点未配置时返回默认(latest_version_code=0=无更新)、内部写端点鉴权
|
||||
(503 未配 / 401 头错 / 200)、写后能读到同一组值、写入参数校验(422)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.app_config import AppConfig
|
||||
from app.repositories.app_config import APP_VERSION_KEY
|
||||
|
||||
_SECRET = "test-internal-secret-only-for-pytest"
|
||||
|
||||
# 合法 body(够过 pydantic 校验,用于鉴权用例)
|
||||
_VALID = {
|
||||
"latest_version_code": 31,
|
||||
"latest_version_name": "0.1.6",
|
||||
"apk_url": "https://app-api.shaguabijia.com/media/releases/shaguabijia-v0.1.6-build31.apk",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def clean_version():
|
||||
"""每个用例前后清掉 latest_app_version 行(表由 conftest 建好,session 级共享)。"""
|
||||
def _clear():
|
||||
s = SessionLocal()
|
||||
s.query(AppConfig).filter(AppConfig.key == APP_VERSION_KEY).delete()
|
||||
s.commit()
|
||||
s.close()
|
||||
_clear()
|
||||
yield
|
||||
_clear()
|
||||
|
||||
|
||||
def test_read_default_when_unset(client: TestClient, clean_version) -> None:
|
||||
"""未配置任何版本时,公开读端点返回 latest_version_code=0(客户端视为已是最新);不鉴权。"""
|
||||
r = client.get("/api/v1/platform/app-version")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["latest_version_code"] == 0
|
||||
|
||||
|
||||
def test_write_secret_unset_503(client: TestClient, clean_version, monkeypatch) -> None:
|
||||
monkeypatch.setattr(settings, "INTERNAL_API_SECRET", "") # 未配 = 写端点关闭
|
||||
r = client.post("/internal/app-version", json=_VALID)
|
||||
assert r.status_code == 503
|
||||
|
||||
|
||||
def test_write_auth(client: TestClient, clean_version, monkeypatch) -> None:
|
||||
monkeypatch.setattr(settings, "INTERNAL_API_SECRET", _SECRET)
|
||||
assert client.post("/internal/app-version", json=_VALID).status_code == 401
|
||||
assert client.post(
|
||||
"/internal/app-version", json=_VALID, headers={"X-Internal-Secret": "wrong"}
|
||||
).status_code == 401
|
||||
r = client.post("/internal/app-version", json=_VALID, headers={"X-Internal-Secret": _SECRET})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_write_then_read(client: TestClient, clean_version, monkeypatch) -> None:
|
||||
monkeypatch.setattr(settings, "INTERNAL_API_SECRET", _SECRET)
|
||||
payload = {
|
||||
"latest_version_code": 99,
|
||||
"latest_version_name": "9.9.9",
|
||||
"apk_url": "https://app-api.shaguabijia.com/media/releases/shaguabijia-v9.9.9-build99.apk",
|
||||
"update_note": "测试更新说明",
|
||||
"min_supported_version_code": 50,
|
||||
"apk_size_bytes": 12345678,
|
||||
"apk_sha256": "abc123",
|
||||
}
|
||||
w = client.post("/internal/app-version", json=payload, headers={"X-Internal-Secret": _SECRET})
|
||||
assert w.status_code == 200, w.text
|
||||
assert w.json()["latest_version_code"] == 99
|
||||
|
||||
body = client.get("/api/v1/platform/app-version").json()
|
||||
assert body["latest_version_code"] == 99
|
||||
assert body["latest_version_name"] == "9.9.9"
|
||||
assert body["apk_url"] == payload["apk_url"]
|
||||
assert body["update_note"] == "测试更新说明"
|
||||
assert body["min_supported_version_code"] == 50
|
||||
assert body["apk_size_bytes"] == 12345678
|
||||
assert body["apk_sha256"] == "abc123"
|
||||
|
||||
|
||||
def test_write_rejects_invalid(client: TestClient, clean_version, monkeypatch) -> None:
|
||||
monkeypatch.setattr(settings, "INTERNAL_API_SECRET", _SECRET)
|
||||
h = {"X-Internal-Secret": _SECRET}
|
||||
# latest_version_code 必须 > 0
|
||||
bad_code = {"latest_version_code": 0, "latest_version_name": "x", "apk_url": "u"}
|
||||
assert client.post("/internal/app-version", json=bad_code, headers=h).status_code == 422
|
||||
# apk_url 不能空
|
||||
bad_url = {"latest_version_code": 5, "latest_version_name": "x", "apk_url": ""}
|
||||
assert client.post("/internal/app-version", json=bad_url, headers=h).status_code == 422
|
||||
Reference in New Issue
Block a user