Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fbc5d8e03 | |||
| 43e45baecf | |||
| 5f9af204ea | |||
| 3d07461ef5 | |||
| db399a40fa | |||
| a753843804 | |||
| bf82c68408 | |||
| 0ae19dc49c | |||
| 389ece9052 | |||
| 455884401f | |||
| dacb37e3e1 | |||
| 988576e14b | |||
| 0890e693d7 | |||
| d94065c5af | |||
| 0bddce516f | |||
| 90fe6d6aa7 | |||
| 47d74273c9 |
@@ -0,0 +1,26 @@
|
||||
"""merge user_force_onboarding and comparison_idx_coupon_daily heads
|
||||
|
||||
Revision ID: 9b894f5fff05
|
||||
Revises: d4b87c5847de, user_force_onboarding
|
||||
Create Date: 2026-06-10 22:49:02.248506
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '9b894f5fff05'
|
||||
down_revision: Union[str, Sequence[str], None] = ('d4b87c5847de', 'user_force_onboarding')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,48 @@
|
||||
"""add composite index (status, created_at) on comparison_record
|
||||
|
||||
首页轮播 feed(按 status='success' 过滤 + created_at 近期排序)与省钱战绩聚合
|
||||
都吃这个过滤+排序;复合索引避免随数据量增大退化成全表扫。
|
||||
|
||||
Revision ID: comparison_status_created_idx
|
||||
Revises: a8c47fc4dc39
|
||||
Create Date: 2026-06-10
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "comparison_status_created_idx"
|
||||
down_revision = "a8c47fc4dc39"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
INDEX_NAME = "ix_comparison_status_created"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name == "postgresql":
|
||||
# PG 上 comparison_record 已有数据量, 普通 CREATE INDEX 持表写锁会阻塞线上写入;
|
||||
# 用 CONCURRENTLY 不锁表(须脱离事务, autocommit_block 切到自动提交)。
|
||||
with op.get_context().autocommit_block():
|
||||
op.create_index(
|
||||
INDEX_NAME, "comparison_record", ["status", "created_at"],
|
||||
unique=False, postgresql_concurrently=True,
|
||||
)
|
||||
else:
|
||||
op.create_index(
|
||||
INDEX_NAME, "comparison_record", ["status", "created_at"], unique=False
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name == "postgresql":
|
||||
with op.get_context().autocommit_block():
|
||||
op.drop_index(
|
||||
INDEX_NAME, table_name="comparison_record",
|
||||
postgresql_concurrently=True,
|
||||
)
|
||||
else:
|
||||
op.drop_index(INDEX_NAME, table_name="comparison_record")
|
||||
@@ -0,0 +1,58 @@
|
||||
"""coupon daily completion table(今日跑完整轮领券 → 首页置灰源)
|
||||
|
||||
Revision ID: coupon_daily_completion
|
||||
Revises: f8d3b1e60a27
|
||||
Create Date: 2026-06-10 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "coupon_daily_completion"
|
||||
down_revision: str | Sequence[str] | None = "f8d3b1e60a27"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"coupon_daily_completion",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("device_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("complete_date", sa.Date(), nullable=False),
|
||||
sa.Column("trace_id", sa.String(length=64), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("(CURRENT_TIMESTAMP)"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("(CURRENT_TIMESTAMP)"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("device_id", "complete_date", name="uq_coupon_completion_device_date"),
|
||||
)
|
||||
with op.batch_alter_table("coupon_daily_completion", schema=None) as batch_op:
|
||||
batch_op.create_index(
|
||||
batch_op.f("ix_coupon_daily_completion_user_id"), ["user_id"], unique=False
|
||||
)
|
||||
batch_op.create_index(
|
||||
batch_op.f("ix_coupon_daily_completion_trace_id"), ["trace_id"], unique=False
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("coupon_daily_completion", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_coupon_daily_completion_trace_id"))
|
||||
batch_op.drop_index(batch_op.f("ix_coupon_daily_completion_user_id"))
|
||||
op.drop_table("coupon_daily_completion")
|
||||
@@ -0,0 +1,26 @@
|
||||
"""merge comparison idx and coupon daily completion heads
|
||||
|
||||
Revision ID: d4b87c5847de
|
||||
Revises: comparison_status_created_idx, onboarding_completion
|
||||
Create Date: 2026-06-10 16:45:50.285917
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'd4b87c5847de'
|
||||
down_revision: Union[str, Sequence[str], None] = ('comparison_status_created_idx', 'onboarding_completion')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,39 @@
|
||||
"""onboarding_completion table (新手引导完成标记,按 user+device 去重)
|
||||
|
||||
Revision ID: onboarding_completion
|
||||
Revises: coupon_daily_completion
|
||||
Create Date: 2026-06-10 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'onboarding_completion'
|
||||
down_revision: Union[str, Sequence[str], None] = 'coupon_daily_completion'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# (账号, 设备) 一条完成标记;device_id = 客户端硬件级 ANDROID_ID(跨卸载重装稳定)。
|
||||
op.create_table(
|
||||
'onboarding_completion',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('device_id', sa.String(length=64), nullable=False),
|
||||
sa.Column('completed_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'device_id', name='uq_onboarding_user_device'),
|
||||
)
|
||||
with op.batch_alter_table('onboarding_completion', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_onboarding_completion_user_id'), ['user_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('onboarding_completion', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_onboarding_completion_user_id'))
|
||||
op.drop_table('onboarding_completion')
|
||||
@@ -0,0 +1,39 @@
|
||||
"""add user.force_onboarding
|
||||
|
||||
运营后台「一键开启新手引导」:置 true → 用户下次启动 App 被强制重走引导教程(即便本地已完成),
|
||||
走完引导(/api/v1/user/onboarding/complete)后端自动清回 false,不无限循环。
|
||||
见 app/models/user.py force_onboarding 列、admin POST /admin/api/users/{id}/force-onboarding。
|
||||
|
||||
Revision ID: user_force_onboarding
|
||||
Revises: onboarding_completion
|
||||
Create Date: 2026-06-10 21:30:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "user_force_onboarding"
|
||||
down_revision: Union[str, Sequence[str], None] = "onboarding_completion"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# sa.false() 渲染成 PG 的 false / SQLite 的 0,两端兼容(同 debug_trace_enabled)。
|
||||
op.add_column(
|
||||
"user",
|
||||
sa.Column(
|
||||
"force_onboarding",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("user", "force_onboarding")
|
||||
@@ -13,6 +13,7 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.admin.routers.ad_audit import router as ad_audit_router
|
||||
from app.admin.routers.admins import router as admins_router
|
||||
from app.admin.routers.audit import router as audit_router
|
||||
from app.admin.routers.auth import router as auth_router
|
||||
@@ -86,3 +87,4 @@ admin_app.include_router(feedback_router)
|
||||
admin_app.include_router(admins_router)
|
||||
admin_app.include_router(audit_router)
|
||||
admin_app.include_router(config_router)
|
||||
admin_app.include_router(ad_audit_router)
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""看广告金币审计:复算 expected_coin 并与实发对比。
|
||||
|
||||
只读。复用 [app.core.rewards] 的公式函数(不另写公式,避免与正式发奖口径漂移):
|
||||
- 看视频:每条 granted = 1 份,第 N 份 = 当日该用户 granted 的 reward_video 顺序号
|
||||
(与 ad_reward.grant_ad_reward 里 `_granted_today + 1` 一致)。
|
||||
- 信息流:每条按 unit_count 份逐份累加,LT 序号 = 当日该用户已 granted 份数累计
|
||||
(与 ad_feed_reward._unit_reward_total 的 existing_units 一致)。
|
||||
|
||||
非 granted(capped/ecpm_missing)不占用份序号、应发恒 0,据此校验闸口是否确实没发。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||
from app.models.ad_reward import AdRewardRecord
|
||||
from app.repositories.ad_feed_reward import FEED_REWARD_UNIT_SECONDS
|
||||
|
||||
|
||||
def _reward_video_rows(
|
||||
db: Session, *, date: str, user_id: int | None
|
||||
) -> list[dict]:
|
||||
"""看视频记录复算。按 (user_id, created_at) 升序还原当日第 N 份。"""
|
||||
stmt = (
|
||||
select(AdRewardRecord)
|
||||
.where(
|
||||
AdRewardRecord.reward_date == date,
|
||||
AdRewardRecord.reward_scene == "reward_video",
|
||||
)
|
||||
.order_by(AdRewardRecord.user_id, AdRewardRecord.created_at)
|
||||
)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(AdRewardRecord.user_id == user_id)
|
||||
|
||||
granted_n: dict[int, int] = {} # user_id -> 已 granted 份数
|
||||
rows: list[dict] = []
|
||||
for rec in db.execute(stmt).scalars():
|
||||
if rec.status == "granted":
|
||||
nth = granted_n.get(rec.user_id, 0) + 1
|
||||
granted_n[rec.user_id] = nth
|
||||
expected = rewards.calculate_ad_reward_coin(rec.ecpm_raw, nth)
|
||||
rows.append({
|
||||
"scene": "reward_video",
|
||||
"record_id": rec.id,
|
||||
"user_id": rec.user_id,
|
||||
"created_at": rec.created_at,
|
||||
"status": rec.status,
|
||||
"ecpm": rec.ecpm_raw,
|
||||
"ecpm_factor": rewards.ad_ecpm_factor(rewards.parse_ecpm_yuan(rec.ecpm_raw)),
|
||||
"units": 1,
|
||||
"lt_index_start": nth,
|
||||
"lt_index_end": nth,
|
||||
"lt_factor_start": rewards.ad_lt_factor(nth),
|
||||
"lt_factor_end": rewards.ad_lt_factor(nth),
|
||||
"expected_coin": expected,
|
||||
"actual_coin": rec.coin,
|
||||
"matched": expected == rec.coin,
|
||||
})
|
||||
else:
|
||||
# capped / ecpm_missing:不发金币,校验实发确为 0
|
||||
rows.append({
|
||||
"scene": "reward_video",
|
||||
"record_id": rec.id,
|
||||
"user_id": rec.user_id,
|
||||
"created_at": rec.created_at,
|
||||
"status": rec.status,
|
||||
"ecpm": rec.ecpm_raw,
|
||||
"ecpm_factor": None,
|
||||
"units": 1,
|
||||
"lt_index_start": None,
|
||||
"lt_index_end": None,
|
||||
"lt_factor_start": None,
|
||||
"lt_factor_end": None,
|
||||
"expected_coin": 0,
|
||||
"actual_coin": rec.coin,
|
||||
"matched": rec.coin == 0,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
|
||||
"""信息流记录复算。granted 记录逐份累加,LT 序号沿用当日累计份数。"""
|
||||
stmt = (
|
||||
select(AdFeedRewardRecord)
|
||||
.where(AdFeedRewardRecord.reward_date == date)
|
||||
.order_by(AdFeedRewardRecord.user_id, AdFeedRewardRecord.created_at)
|
||||
)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(AdFeedRewardRecord.user_id == user_id)
|
||||
|
||||
granted_units: dict[int, int] = {} # user_id -> 已 granted 份数累计
|
||||
rows: list[dict] = []
|
||||
for rec in db.execute(stmt).scalars():
|
||||
if rec.status == "granted":
|
||||
existing = granted_units.get(rec.user_id, 0)
|
||||
units = rec.unit_count
|
||||
expected = sum(
|
||||
rewards.calculate_ad_reward_coin(rec.ecpm_raw, existing + offset)
|
||||
for offset in range(1, units + 1)
|
||||
)
|
||||
granted_units[rec.user_id] = existing + units
|
||||
start = existing + 1 if units > 0 else None
|
||||
end = existing + units if units > 0 else None
|
||||
rows.append({
|
||||
"scene": "feed",
|
||||
"record_id": rec.id,
|
||||
"user_id": rec.user_id,
|
||||
"created_at": rec.created_at,
|
||||
"status": rec.status,
|
||||
"ecpm": rec.ecpm_raw,
|
||||
"ecpm_factor": rewards.ad_ecpm_factor(rewards.parse_ecpm_yuan(rec.ecpm_raw)),
|
||||
"units": units,
|
||||
"lt_index_start": start,
|
||||
"lt_index_end": end,
|
||||
"lt_factor_start": rewards.ad_lt_factor(start) if start else None,
|
||||
"lt_factor_end": rewards.ad_lt_factor(end) if end else None,
|
||||
"expected_coin": expected,
|
||||
"actual_coin": rec.coin,
|
||||
"matched": expected == rec.coin,
|
||||
})
|
||||
else:
|
||||
rows.append({
|
||||
"scene": "feed",
|
||||
"record_id": rec.id,
|
||||
"user_id": rec.user_id,
|
||||
"created_at": rec.created_at,
|
||||
"status": rec.status,
|
||||
"ecpm": rec.ecpm_raw,
|
||||
"ecpm_factor": None,
|
||||
"units": rec.unit_count,
|
||||
"lt_index_start": None,
|
||||
"lt_index_end": None,
|
||||
"lt_factor_start": None,
|
||||
"lt_factor_end": None,
|
||||
"expected_coin": 0,
|
||||
"actual_coin": rec.coin,
|
||||
"matched": rec.coin == 0,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def ad_coin_audit(
|
||||
db: Session, *, date: str, user_id: int | None, scene: str | None, limit: int
|
||||
) -> list[dict]:
|
||||
"""返回当日发奖复算明细,按 created_at 倒序(最新在前)截断到 limit。
|
||||
|
||||
scene: None=两类都要 / "reward_video" / "feed"。
|
||||
份序号在截断前已基于全天数据算好,故 limit 只影响展示条数、不影响 expected 复算正确性。
|
||||
"""
|
||||
rows: list[dict] = []
|
||||
if scene in (None, "reward_video"):
|
||||
rows.extend(_reward_video_rows(db, date=date, user_id=user_id))
|
||||
if scene in (None, "feed"):
|
||||
rows.extend(_feed_rows(db, date=date, user_id=user_id))
|
||||
rows.sort(key=lambda r: r["created_at"], reverse=True)
|
||||
return rows[:limit]
|
||||
|
||||
|
||||
def formula_snapshot() -> dict:
|
||||
"""当前公式参数快照(给前端展示规则参照)。直接读 rewards 常量,与发奖同源。"""
|
||||
return {
|
||||
"coin_per_yuan": rewards.COIN_PER_YUAN,
|
||||
"feed_unit_seconds": FEED_REWARD_UNIT_SECONDS,
|
||||
"ecpm_factor_tiers": [list(t) for t in rewards.AD_ECPM_FACTOR_TABLE],
|
||||
"lt_factor_tiers": [list(t) for t in rewards.AD_LT_FACTOR_TABLE],
|
||||
}
|
||||
@@ -42,6 +42,21 @@ def set_user_debug_trace(
|
||||
return user
|
||||
|
||||
|
||||
def set_user_force_onboarding(
|
||||
db: Session, user: User, *, enabled: bool, commit: bool = True
|
||||
) -> User:
|
||||
"""运营「一键开启/取消新手引导」:置 user.force_onboarding。开启后该用户下次启动 App 被强制
|
||||
重走引导教程,走完即由 /onboarding/complete 自动清回 false。同 set_user_debug_trace:支持
|
||||
commit=False 让 router 把业务写 + 审计写放进同一事务。"""
|
||||
user.force_onboarding = enabled
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
else:
|
||||
db.flush()
|
||||
return user
|
||||
|
||||
|
||||
def update_feedback_status(
|
||||
db: Session, feedback: Feedback, *, status: str, commit: bool = True
|
||||
) -> Feedback:
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""admin 看广告金币审计:只读对账,核对发奖金币是否按公式计算。
|
||||
|
||||
任意已登录 admin 可看(只读,不涉及资金操作)。复算逻辑在 app/admin/repositories/ad_audit.py。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.admin.deps import AdminDb, get_current_admin
|
||||
from app.admin.repositories import ad_audit
|
||||
from app.admin.schemas.ad_audit import AdCoinAuditOut, AdCoinAuditRow, AdCoinFormulaOut
|
||||
from app.core.rewards import cn_today
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/ad-coin-audit",
|
||||
tags=["admin-ad-coin-audit"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=AdCoinAuditOut, summary="看广告金币公式审计(复算对比)")
|
||||
def get_ad_coin_audit(
|
||||
db: AdminDb,
|
||||
date: Annotated[str | None, Query(description="北京时间 YYYY-MM-DD,默认今天")] = None,
|
||||
user_id: Annotated[int | None, Query(description="只看某用户;不传=全部用户")] = None,
|
||||
scene: Annotated[
|
||||
str | None, Query(description="reward_video / feed;不传=两类都要")
|
||||
] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=500)] = 100,
|
||||
) -> AdCoinAuditOut:
|
||||
audit_date = date or cn_today().isoformat()
|
||||
rows = ad_audit.ad_coin_audit(
|
||||
db, date=audit_date, user_id=user_id, scene=scene, limit=limit,
|
||||
)
|
||||
items = [AdCoinAuditRow(**r) for r in rows]
|
||||
return AdCoinAuditOut(
|
||||
date=audit_date,
|
||||
formula=AdCoinFormulaOut(**ad_audit.formula_snapshot()),
|
||||
total=len(items),
|
||||
mismatch_count=sum(1 for it in items if not it.matched),
|
||||
items=items,
|
||||
)
|
||||
@@ -12,6 +12,8 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from app.admin.audit import write_audit
|
||||
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
||||
from app.admin.schemas.ops_marquee_seed import (
|
||||
OpsMarqueeSeedBatchDelete,
|
||||
OpsMarqueeSeedBatchEnable,
|
||||
OpsMarqueeSeedBulkCreate,
|
||||
OpsMarqueeSeedCreate,
|
||||
OpsMarqueeSeedOut,
|
||||
@@ -111,6 +113,40 @@ def bulk_generate(
|
||||
return [_out(s) for s in seeds]
|
||||
|
||||
|
||||
@router.post("/batch-delete", summary="批量删除种子(带审计)")
|
||||
def batch_delete_seeds(
|
||||
body: OpsMarqueeSeedBatchDelete,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> dict:
|
||||
n = ops_marquee.batch_delete(db, body.ids, commit=False)
|
||||
write_audit(
|
||||
db, admin, action="ops_marquee_seed.batch_delete", target_type="ops_marquee_seed",
|
||||
target_id=None, detail={"ids": body.ids, "deleted": n},
|
||||
ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return {"deleted": n}
|
||||
|
||||
|
||||
@router.post("/batch-enable", summary="批量启用 / 停用种子(带审计)")
|
||||
def batch_enable_seeds(
|
||||
body: OpsMarqueeSeedBatchEnable,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> dict:
|
||||
n = ops_marquee.batch_set_enabled(db, body.ids, body.enabled, commit=False)
|
||||
write_audit(
|
||||
db, admin, action="ops_marquee_seed.batch_enable", target_type="ops_marquee_seed",
|
||||
target_id=None, detail={"ids": body.ids, "enabled": body.enabled, "updated": n},
|
||||
ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return {"updated": n}
|
||||
|
||||
|
||||
@router.patch("/{seed_id}", response_model=OpsMarqueeSeedOut, summary="改轮播种子(带审计)")
|
||||
def update_seed(
|
||||
seed_id: int,
|
||||
|
||||
@@ -12,8 +12,10 @@ from app.admin.schemas.common import CursorPage, OkResponse
|
||||
from app.admin.schemas.user import (
|
||||
AdminUserListItem,
|
||||
AdminUserOverview,
|
||||
GrantCashRequest,
|
||||
GrantCoinsRequest,
|
||||
SetDebugTraceRequest,
|
||||
SetForceOnboardingRequest,
|
||||
SetUserStatusRequest,
|
||||
)
|
||||
from app.models.admin import AdminUser
|
||||
@@ -100,6 +102,33 @@ def set_user_debug_trace(
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.post("/{user_id}/force-onboarding", response_model=OkResponse, summary="一键开启/取消新手引导")
|
||||
def set_user_force_onboarding(
|
||||
user_id: int,
|
||||
body: SetForceOnboardingRequest,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> OkResponse:
|
||||
"""运营「一键开启新手引导」:置 user.force_onboarding。开启后该用户下次启动 App(/me 带出此字段)
|
||||
会被强制重走引导教程——即便此前已看完;走完引导后端自动清回 false,不无限循环。
|
||||
仅对支持 force_onboarding 的 App 版本生效(老版本忽略该字段)。"""
|
||||
user = user_repo.get_user_by_id(db, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
if user.status == "deleted":
|
||||
raise HTTPException(status_code=400, detail="已注销账号不可操作")
|
||||
before = user.force_onboarding
|
||||
# 业务写 + 审计写同一事务(commit=False),最后一起 commit(同 set_user_status / debug-trace)
|
||||
mutations.set_user_force_onboarding(db, user, enabled=body.enabled, commit=False)
|
||||
write_audit(
|
||||
db, admin, action="user.force_onboarding.set", target_type="user", target_id=user_id,
|
||||
detail={"before": before, "after": body.enabled}, ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.post("/{user_id}/coins", response_model=OkResponse, summary="手动增减金币(带审计)")
|
||||
def grant_user_coins(
|
||||
user_id: int,
|
||||
@@ -132,3 +161,42 @@ def grant_user_coins(
|
||||
)
|
||||
db.commit()
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.post("/{user_id}/cash", response_model=OkResponse, summary="手动增减现金(带审计)")
|
||||
def grant_user_cash(
|
||||
user_id: int,
|
||||
body: GrantCashRequest,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("finance"))],
|
||||
db: AdminDb,
|
||||
) -> OkResponse:
|
||||
"""给指定用户增/减现金(分)。正=发放、负=扣减;主要用于让无现金用户直接测试提现。"""
|
||||
if body.amount_cents == 0:
|
||||
raise HTTPException(status_code=400, detail="amount_cents 不能为 0")
|
||||
user = user_repo.get_user_by_id(db, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
# 负数扣减时不允许扣成负余额(运营误操作保护)
|
||||
if body.amount_cents < 0:
|
||||
acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False)
|
||||
if acc_now.cash_balance_cents + body.amount_cents < 0:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"扣减后现金为负(当前余额 {acc_now.cash_balance_cents} 分)"
|
||||
)
|
||||
biz_type = "admin_grant" if body.amount_cents > 0 else "admin_deduct"
|
||||
# grant_cash 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕)
|
||||
acc, _ = wallet_repo.grant_cash(
|
||||
db, user_id, body.amount_cents, biz_type=biz_type, remark=f"admin:{body.reason}"[:128],
|
||||
)
|
||||
write_audit(
|
||||
db, admin, action="user.cash.grant", target_type="user", target_id=user_id,
|
||||
detail={
|
||||
"amount_cents": body.amount_cents,
|
||||
"balance_after_cents": acc.cash_balance_cents,
|
||||
"reason": body.reason,
|
||||
},
|
||||
ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return OkResponse()
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""看广告金币审计 schemas。
|
||||
|
||||
只读对账视图:把"看视频"(ad_reward_record)和"信息流"(ad_feed_reward_record)两类发奖记录,
|
||||
用与正式发奖相同的公式 [app.core.rewards.calculate_ad_reward_coin] 复算一遍 expected_coin,
|
||||
和实际入账的 actual_coin 对比,核对金币公式是否生效。字段 snake_case、金额按金币整数。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AdCoinAuditRow(BaseModel):
|
||||
"""单条发奖记录的复算明细。"""
|
||||
|
||||
scene: str = Field(..., description="reward_video(看视频) / feed(信息流)")
|
||||
record_id: int = Field(..., description="对应记录表主键")
|
||||
user_id: int
|
||||
created_at: datetime
|
||||
status: str = Field(..., description="granted / capped / ecpm_missing")
|
||||
ecpm: str | None = Field(None, description="本次采用的 eCPM 原始值(分/千次展示,SDK getEcpm 原值)")
|
||||
ecpm_factor: float | None = Field(None, description="因子1(eCPM 档)")
|
||||
units: int = Field(..., description="折算份数:看视频恒为 1;信息流 = 满 10 秒的份数")
|
||||
lt_index_start: int | None = Field(None, description="本条占用的当日第几份(起)")
|
||||
lt_index_end: int | None = Field(None, description="本条占用的当日第几份(止);看视频 = 起")
|
||||
lt_factor_start: float | None = Field(None, description="因子2(LT)起值")
|
||||
lt_factor_end: float | None = Field(None, description="因子2(LT)止值;看视频 = 起值")
|
||||
expected_coin: int = Field(..., description="按公式复算应发金币")
|
||||
actual_coin: int = Field(..., description="实际入账金币")
|
||||
matched: bool = Field(..., description="复算与实发是否一致(capped/ecpm_missing 校验是否确为 0)")
|
||||
|
||||
|
||||
class AdCoinFormulaOut(BaseModel):
|
||||
"""当前金币公式参数(给前端展示规则参照)。"""
|
||||
|
||||
description: str = Field(
|
||||
"eCPM元 = getEcpm分 ÷ 100;单份金币 = round(eCPM元 ÷ 1000 × 因子1 × 因子2 × coin_per_yuan);"
|
||||
"因子1 按 eCPM元 判档(阈值 100/200/400 元)",
|
||||
description="公式说明",
|
||||
)
|
||||
coin_per_yuan: int = Field(..., description="金币:元 汇率")
|
||||
ecpm_unit: str = Field("分/千次展示(SDK getEcpm 原值)", description="eCPM 口径")
|
||||
feed_unit_seconds: int = Field(..., description="信息流每多少秒折 1 份")
|
||||
# [(因子值, 区间下限, 区间上限或 null)]
|
||||
ecpm_factor_tiers: list[tuple[float, int, int | None]] = Field(..., description="因子1 档位表")
|
||||
lt_factor_tiers: list[tuple[float, int, int | None]] = Field(..., description="因子2 LT 档位表")
|
||||
|
||||
|
||||
class AdCoinAuditOut(BaseModel):
|
||||
"""审计响应:公式参照 + 命中条数 + 明细。"""
|
||||
|
||||
date: str = Field(..., description="审计日期(北京时间 YYYY-MM-DD)")
|
||||
formula: AdCoinFormulaOut
|
||||
total: int = Field(..., description="返回的明细条数")
|
||||
mismatch_count: int = Field(..., description="其中 matched=false 的条数(=0 说明公式全部生效)")
|
||||
items: list[AdCoinAuditRow]
|
||||
@@ -4,7 +4,7 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class OpsMarqueeSeedOut(BaseModel):
|
||||
@@ -42,6 +42,17 @@ class OpsMarqueeSeedBulkCreate(BaseModel):
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class OpsMarqueeSeedBatchDelete(BaseModel):
|
||||
"""批量删除选中的种子。"""
|
||||
ids: list[int] = Field(..., max_length=500) # 上限防超大列表(审计行/IN 子句过大)
|
||||
|
||||
|
||||
class OpsMarqueeSeedBatchEnable(BaseModel):
|
||||
"""批量启用 / 停用选中的种子。"""
|
||||
ids: list[int] = Field(..., max_length=500)
|
||||
enabled: bool
|
||||
|
||||
|
||||
class OpsSavingsFeedPreviewItem(BaseModel):
|
||||
masked_user: str
|
||||
saved_amount_cents: int
|
||||
|
||||
@@ -20,7 +20,7 @@ class OpsStatItemOut(BaseModel):
|
||||
random_kind: str # mult(×倍率) / add(+绝对增量)
|
||||
random_step_min: int # 绝对增量区间(基础单位;total_saved 为分)
|
||||
random_step_max: int
|
||||
real_offset: int # 真实值模式基数偏移(基础单位)
|
||||
real_offset: int # 真实值模式保底值(基础单位):展示=max(真实,保底)。列名沿用 real_offset
|
||||
allow_decrease: bool # 是否允许展示值下降(默认 false = 只增不减)
|
||||
random_current: int | None = None
|
||||
random_last_tick_at: str | None = None
|
||||
|
||||
@@ -16,6 +16,8 @@ class AdminUserListItem(BaseModel):
|
||||
register_channel: str
|
||||
status: str
|
||||
debug_trace_enabled: bool = False
|
||||
# 运营「一键开启新手引导」:true=该用户下次启动 App 被强制重走引导(走完自动清回 false)
|
||||
force_onboarding: bool = False
|
||||
wechat_openid: str | None = None
|
||||
created_at: datetime
|
||||
last_login_at: datetime
|
||||
@@ -42,6 +44,11 @@ class GrantCoinsRequest(BaseModel):
|
||||
reason: str = Field(..., min_length=1, max_length=128, description="操作原因(必填,入审计)")
|
||||
|
||||
|
||||
class GrantCashRequest(BaseModel):
|
||||
amount_cents: int = Field(..., description="现金变动(分):正=增加,负=扣减(不可为 0)")
|
||||
reason: str = Field(..., min_length=1, max_length=128, description="操作原因(必填,入审计)")
|
||||
|
||||
|
||||
class SetUserStatusRequest(BaseModel):
|
||||
status: Literal["active", "disabled"] = Field(
|
||||
..., description="active=解封 / disabled=封禁(注销 deleted 不走此接口)"
|
||||
@@ -50,3 +57,9 @@ class SetUserStatusRequest(BaseModel):
|
||||
|
||||
class SetDebugTraceRequest(BaseModel):
|
||||
enabled: bool = Field(..., description="是否给该用户开「复制调试链接」权限")
|
||||
|
||||
|
||||
class SetForceOnboardingRequest(BaseModel):
|
||||
enabled: bool = Field(
|
||||
..., description="true=一键开启(强制该用户下次启动 App 重走新手引导)/ false=取消"
|
||||
)
|
||||
|
||||
+10
-2
@@ -304,10 +304,18 @@ def test_grant(user: CurrentUser, db: DbSession, payload: TestGrantIn | None = N
|
||||
db.commit()
|
||||
db.refresh(rec)
|
||||
else:
|
||||
# 优先用客户端按 ad_session_id 上报的真实 eCPM(走与正式发奖相同的公式);
|
||||
# 取不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍能验出非零金币。
|
||||
ad_session_id = payload.ad_session_id if payload is not None else None
|
||||
ecpm_val = "200"
|
||||
if ad_session_id:
|
||||
ecpm_rec = crud_ecpm.find_by_session(db, user_id=user.id, ad_session_id=ad_session_id)
|
||||
if ecpm_rec is not None and rewards.parse_ecpm_fen(ecpm_rec.ecpm_raw) > 0:
|
||||
ecpm_val = ecpm_rec.ecpm_raw
|
||||
try:
|
||||
rec = crud_ad.grant_ad_reward(
|
||||
db, user.id, trans_id, ecpm="200", reward_name="测试发奖",
|
||||
raw="client debug test-grant ecpm=200",
|
||||
db, user.id, trans_id, ecpm=ecpm_val, ad_session_id=ad_session_id,
|
||||
reward_name="测试发奖", raw=f"client debug test-grant ecpm={ecpm_val}",
|
||||
)
|
||||
except crud_ad.UnknownUserError as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e
|
||||
|
||||
+10
-6
@@ -19,6 +19,7 @@ from app.core.ratelimit import rate_limit
|
||||
from app.core.security import TokenError, decode_token, issue_token_pair
|
||||
from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone
|
||||
from app.integrations.sms import SmsError, send_code, verify_code
|
||||
from app.repositories import onboarding as onboarding_repo
|
||||
from app.repositories import user as user_repo
|
||||
from app.schemas.auth import (
|
||||
JverifyLoginRequest,
|
||||
@@ -37,12 +38,13 @@ logger = logging.getLogger("shagua.auth")
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
|
||||
def _login_response(db, user) -> TokenWithUser:
|
||||
"""登录类接口共用的响应组装。"""
|
||||
def _login_response(user, *, onboarding_completed: bool) -> TokenWithUser:
|
||||
"""登录类接口共用的响应组装。onboarding_completed 据登录请求的 device_id 算好后传入。"""
|
||||
tokens = issue_token_pair(user.id)
|
||||
return TokenWithUser(
|
||||
**tokens,
|
||||
user=UserOut.model_validate(user),
|
||||
onboarding_completed=onboarding_completed,
|
||||
)
|
||||
|
||||
|
||||
@@ -66,8 +68,9 @@ def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser:
|
||||
if user.status != "active":
|
||||
raise HTTPException(status_code=403, detail="account disabled")
|
||||
|
||||
logger.info("jverify_login ok user_id=%d phone=%s", user.id, mask_phone(phone))
|
||||
return _login_response(db, user)
|
||||
completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id)
|
||||
logger.info("jverify_login ok user_id=%d phone=%s onboarded=%s", user.id, mask_phone(phone), completed)
|
||||
return _login_response(user, onboarding_completed=completed)
|
||||
|
||||
|
||||
# ===================== 短信登录 =====================
|
||||
@@ -103,8 +106,9 @@ def sms_login(req: SmsLoginRequest, db: DbSession) -> TokenWithUser:
|
||||
if user.status != "active":
|
||||
raise HTTPException(status_code=403, detail="account disabled")
|
||||
|
||||
logger.info("sms_login ok user_id=%d phone=%s", user.id, mask_phone(req.phone))
|
||||
return _login_response(db, user)
|
||||
completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id)
|
||||
logger.info("sms_login ok user_id=%d phone=%s onboarded=%s", user.id, mask_phone(req.phone), completed)
|
||||
return _login_response(user, onboarding_completed=completed)
|
||||
|
||||
|
||||
# ===================== Refresh =====================
|
||||
|
||||
@@ -102,6 +102,17 @@ async def intent_step(request: Request) -> dict[str, Any]:
|
||||
return await _passthrough(request, "/api/intent/step")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/intent/precoupon/step",
|
||||
summary="外卖比价 Phase 0 意图识别前先用券 (透传到 pricebot, 仅美团源)",
|
||||
)
|
||||
async def intent_precoupon_step(request: Request) -> dict[str, Any]:
|
||||
# 美团源平台『意图识别前先用券』多帧循环: 客户端在调 /intent/recognize 之前先循环
|
||||
# 调本端点到 done(continue=false)。订单页底部有『点击使用X红包』就自动选最大免费券
|
||||
# 用上, 已用券/无券则首帧秒过。与 /intent/step 同属 intent 域, 复用同一透传壳。
|
||||
return await _passthrough(request, "/api/intent/precoupon/step")
|
||||
|
||||
|
||||
@router.post("/price/step", summary="外卖比价 Phase 2 步进 (透传到 pricebot)")
|
||||
async def price_step(request: Request) -> dict[str, Any]:
|
||||
return await _passthrough(request, "/api/price/step")
|
||||
|
||||
+42
-1
@@ -23,7 +23,11 @@ from app.core.config import settings
|
||||
from app.core.pricebot_router import pick_pricebot
|
||||
from app.db.session import SessionLocal
|
||||
from app.repositories import coupon_state as coupon_repo
|
||||
from app.schemas.coupon_state import CouponPromptDismissIn, CouponPromptShouldShowOut
|
||||
from app.schemas.coupon_state import (
|
||||
CouponCompletedTodayOut,
|
||||
CouponPromptDismissIn,
|
||||
CouponPromptShouldShowOut,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.coupon")
|
||||
|
||||
@@ -76,6 +80,14 @@ def _record_claims_blocking(
|
||||
coupon_repo.record_claims(db, device_id, user_id, trace_id, results)
|
||||
|
||||
|
||||
def _mark_completed_blocking(
|
||||
device_id: str, user_id: int | None, trace_id: str | None
|
||||
) -> None:
|
||||
"""独立 session 写"今日已跑完整轮"(到 done 帧那刻调,best-effort 不阻塞领券)。"""
|
||||
with SessionLocal() as db:
|
||||
coupon_repo.mark_completed_today(db, device_id, user_id, trace_id)
|
||||
|
||||
|
||||
@router.post("/step", summary="领券任务步进 (透传到 pricebot)")
|
||||
async def coupon_step(
|
||||
request: Request,
|
||||
@@ -160,6 +172,19 @@ async def coupon_step(
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("coupon claim write failed: %s", e)
|
||||
|
||||
# 整轮跑完(done 帧)→ 记一条"今日已完成",首页「去领取」卡据此置灰。
|
||||
# pricebot 把中途单券 done 改写成 wait+continue=true,只有整套全跑完那帧才保留
|
||||
# command=="done"(见 pricebot main.py),故 done 已等价"整轮完成"(用户决策 A 方案:
|
||||
# 到 done 即算,不管单券成败),无需再判 continue。写库失败绝不连累领券返回。
|
||||
action = resp_json.get("action") or {}
|
||||
if device_id and action.get("command") == "done":
|
||||
try:
|
||||
await run_in_threadpool(
|
||||
_mark_completed_blocking, device_id, user_id, trace_id
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("coupon completion write failed: %s", e)
|
||||
|
||||
return resp_json
|
||||
|
||||
|
||||
@@ -195,3 +220,19 @@ def coupon_prompt_reset(payload: CouponPromptDismissIn, db: DbSession) -> dict[s
|
||||
开发设置「重置今日领券弹窗状态」按钮调。MVP 不鉴权,按 device_id。"""
|
||||
coupon_repo.reset_today_engagement(db, payload.device_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/completed-today",
|
||||
response_model=CouponCompletedTodayOut,
|
||||
summary="这台设备今天是否已跑完整轮领券(到 done 帧)",
|
||||
)
|
||||
def coupon_completed_today(
|
||||
device_id: str, db: DbSession
|
||||
) -> CouponCompletedTodayOut:
|
||||
"""今天这台设备已跑完整轮(到 done)→ completed=true。客户端据此把首页「去领取」
|
||||
卡置灰、不可点(用户决策 A 方案:到 done 即算,不管单券成败)。判断维度 device_id
|
||||
必须与领券循环上报的一致(客户端两端都用 ANDROID_ID)。"""
|
||||
return CouponCompletedTodayOut(
|
||||
completed=coupon_repo.has_completed_today(db, device_id)
|
||||
)
|
||||
|
||||
+148
-54
@@ -8,12 +8,12 @@ import logging
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import nullslast, select
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.session import get_db
|
||||
from app.integrations.meituan import MeituanCpsError, get_referral_link, query_coupon
|
||||
from app.integrations.meituan import MeituanCpsError, _call, get_referral_link, query_coupon
|
||||
from app.models.meituan_coupon import MeituanCoupon
|
||||
from app.schemas.meituan import (
|
||||
CouponCard,
|
||||
@@ -34,7 +34,7 @@ router = APIRouter(prefix="/api/v1/meituan", tags=["meituan-cps"])
|
||||
@router.post("/coupons", response_model=CouponListResponse, summary="券列表(为您推荐)")
|
||||
def list_coupons(req: CouponListRequest) -> CouponListResponse:
|
||||
if not settings.mt_cps_configured:
|
||||
return CouponListResponse(items=[], has_next=False, search_id=None)
|
||||
return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded")
|
||||
logger.info("[coupons] lon=%.6f lat=%.6f topic=%s", req.longitude, req.latitude, req.list_topic_id)
|
||||
try:
|
||||
raw = query_coupon(
|
||||
@@ -50,14 +50,21 @@ def list_coupons(req: CouponListRequest) -> CouponListResponse:
|
||||
page_size=req.page_size,
|
||||
)
|
||||
except MeituanCpsError as e:
|
||||
logger.error("query_coupon failed: %s", e)
|
||||
raise HTTPException(status_code=502, detail=f"meituan: {e}") from e
|
||||
# 软降级:不再抛 502(避免前端弹报错 toast),返空 + degraded,前端显示「服务繁忙」。
|
||||
logger.warning("[coupons] query_coupon 失败,降级返空: %s", e)
|
||||
return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded")
|
||||
|
||||
items = [CouponCard.from_raw(it) for it in (raw.get("data") or [])]
|
||||
items: list[CouponCard] = []
|
||||
for it in (raw.get("data") or []):
|
||||
try:
|
||||
items.append(CouponCard.from_raw(it))
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
return CouponListResponse(
|
||||
items=items,
|
||||
has_next=raw.get("hasNext", False),
|
||||
search_id=raw.get("searchId"),
|
||||
status="ok" if items else "empty",
|
||||
)
|
||||
|
||||
|
||||
@@ -93,59 +100,136 @@ def _commission_pct(card: CouponCard) -> float:
|
||||
|
||||
|
||||
@router.post("/feed", response_model=FeedResponse, summary="混合feed(外卖+到店交叉);tab=rec智能推荐/distance距离最近")
|
||||
def feed(req: FeedRequest) -> FeedResponse:
|
||||
if not settings.mt_cps_configured:
|
||||
return FeedResponse(items=[], has_next=False, page=req.page)
|
||||
def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse:
|
||||
lon, lat = req.longitude, req.latitude
|
||||
tab = (req.tab or "").strip()
|
||||
logger.info("[feed] tab=%s page=%s lon=%.6f lat=%.6f", tab or "(default)", req.page, lon, lat)
|
||||
|
||||
def _fetch_topic(platform: int, biz_line: int | None, topic: int) -> list[dict]:
|
||||
# rec 是纯离线库查询,不依赖 MT 凭证、不实时打美团;其余 tab(distance / 默认)要实时打美团,
|
||||
# 未配凭证直接降级返空(degraded),让前端区分「服务暂不可用」而非「暂无」。
|
||||
if tab != "rec" and not settings.mt_cps_configured:
|
||||
return FeedResponse(items=[], has_next=False, page=req.page, status="degraded")
|
||||
|
||||
def _fetch_topic(platform: int, biz_line: int | None, topic: int) -> tuple[list[dict], bool]:
|
||||
"""返回(items, 是否调用失败)。失败标志用于把默认 feed 整页标 degraded。"""
|
||||
try:
|
||||
return (query_coupon(
|
||||
data = (query_coupon(
|
||||
longitude=lon, latitude=lat,
|
||||
platform=platform, biz_line=biz_line,
|
||||
list_topic_id=topic, page_size=20,
|
||||
).get("data") or [])
|
||||
return data, False
|
||||
except MeituanCpsError:
|
||||
return []
|
||||
return [], True
|
||||
|
||||
# 距离最近:拉齐全部榜单轮次,合并去重,后端在【完整池子】上全局按距离由近及远排,一次性返回。
|
||||
# (距离排序必须在完整池上做、不能逐页排——这正是之前放前端不合理的根因。)
|
||||
# 距离最近:搜索召回(外卖搜"外卖" + 到店搜"美食",都 sortField=6 离我最近)一页页拉。
|
||||
# 搜索翻页必须用 searchId(pageNo 翻不动),所以每个 feed 页顺序翻到第 N 页;两路并行、page 1 最快。
|
||||
# 无状态、不改 APP(传页码即可);按你位置实时算距离(库里没存 POI 经纬度,只能实时)。
|
||||
if tab == "distance":
|
||||
with ThreadPoolExecutor(max_workers=len(_TOPIC_ROUNDS) * 2) as pool:
|
||||
futs = []
|
||||
for wm_topic, dd_topic in _TOPIC_ROUNDS:
|
||||
futs.append(pool.submit(_fetch_topic, 1, None, wm_topic))
|
||||
futs.append(pool.submit(_fetch_topic, 2, 1, dd_topic))
|
||||
raws = [f.result() for f in futs]
|
||||
lon_i, lat_i = int(lon * 1_000_000), int(lat * 1_000_000)
|
||||
|
||||
def _search_page_n(platform: int, biz_line: int | None, keyword: str, n: int) -> tuple[list[dict], bool, bool]:
|
||||
"""顺序翻到第 n 页(搜索须 searchId 续页),返回(第 n 页 items, 是否还有下一页, 是否调用失败)。"""
|
||||
sid: str | None = None
|
||||
data: list[dict] = []
|
||||
has_next = False
|
||||
for pg in range(1, n + 1):
|
||||
body: dict = {
|
||||
"platform": platform, "searchText": keyword, "sortField": 6,
|
||||
"longitude": lon_i, "latitude": lat_i, "pageSize": 20,
|
||||
}
|
||||
if biz_line is not None:
|
||||
body["bizLine"] = biz_line
|
||||
if sid:
|
||||
body["searchId"] = sid
|
||||
else:
|
||||
body["pageNo"] = 1
|
||||
try:
|
||||
r = _call("/cps_open/common/api/v1/query_coupon", body)
|
||||
except MeituanCpsError:
|
||||
return [], False, True # 调用失败(用于标 degraded)
|
||||
data = r.get("data") or []
|
||||
sid = r.get("searchId")
|
||||
has_next = bool(r.get("hasNext")) and bool(data)
|
||||
if not data or (not has_next and pg < n):
|
||||
return [], False, False # 没那么多页了(非错误)
|
||||
return data, has_next, False
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
f_wm = pool.submit(_search_page_n, 1, None, "外卖", req.page)
|
||||
f_dd = pool.submit(_search_page_n, 2, 1, "美食", req.page)
|
||||
(wm_data, wm_hn, wm_fail), (dd_data, dd_hn, dd_fail) = f_wm.result(), f_dd.result()
|
||||
|
||||
seen: set[str] = set()
|
||||
cards: list[CouponCard] = []
|
||||
for raw_list in raws:
|
||||
for it in raw_list:
|
||||
for it in wm_data + dd_data:
|
||||
try:
|
||||
card = CouponCard.from_raw(it)
|
||||
if card.product_view_sign and card.product_view_sign not in seen:
|
||||
seen.add(card.product_view_sign)
|
||||
cards.append(card)
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
if card.product_view_sign and card.product_view_sign not in seen:
|
||||
seen.add(card.product_view_sign)
|
||||
cards.append(card)
|
||||
cards.sort(key=lambda c: c.distance_meters if c.distance_meters is not None else float("inf"))
|
||||
return FeedResponse(items=cards, has_next=False, page=1)
|
||||
# 两路都失败且无数据 → degraded;否则有数据 ok / 无数据 empty
|
||||
status = "degraded" if (not cards and wm_fail and dd_fail) else ("ok" if cards else "empty")
|
||||
return FeedResponse(items=cards, has_next=wm_hn or dd_hn, page=req.page, status=status)
|
||||
|
||||
# 智能推荐(rec,默认):沿用逐轮分页的混合 feed,后端过滤掉佣金率 < 3%。
|
||||
# 智能推荐(rec):走【离线库】筛佣金率 ≥ 3%,分页返回(SQL 侧去重+排序+分页,秒级、不打美团)。
|
||||
# 实测库里佣金≥3% 去重后仅 ~578 条(几乎全是外卖;到店团购佣金普遍 <3%):实时按"同城热销榜单"
|
||||
# 拉既撞限流、又填不满(该榜单中位佣金 ~0.8%,筛完每页剩 0-1 条),故从库出。佣金阈值逻辑不变。
|
||||
if tab == "rec":
|
||||
PAGE = 20
|
||||
try:
|
||||
base = select(MeituanCoupon).where(MeituanCoupon.commission_percent >= 3.0)
|
||||
deduped = base.distinct(MeituanCoupon.dedup_key).order_by(
|
||||
MeituanCoupon.dedup_key,
|
||||
MeituanCoupon.commission_percent.desc(),
|
||||
).subquery()
|
||||
m = aliased(MeituanCoupon, deduped)
|
||||
start = (req.page - 1) * PAGE
|
||||
rows = db.execute(
|
||||
select(m)
|
||||
# 销量高的优先(无销量档排后),同档佣金高优先,id 兜底稳定分页
|
||||
.order_by(nullslast(m.sale_volume_num.desc()), m.commission_percent.desc(), m.id)
|
||||
.offset(start)
|
||||
.limit(PAGE + 1)
|
||||
).scalars().all()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("[feed] rec 库查询失败,降级返空")
|
||||
return FeedResponse(items=[], has_next=False, page=req.page, status="degraded")
|
||||
has_next = len(rows) > PAGE
|
||||
cards: list[CouponCard] = []
|
||||
for row in rows[:PAGE]:
|
||||
try:
|
||||
card = CouponCard.from_raw(row.raw or {})
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
if card.product_view_sign:
|
||||
# 智能推荐不显示距离:库里的距离是相对城市默认点的(对用户无意义、且误导)。
|
||||
# 置空后前端"距离 店名"那行只剩店名、自动顶到最左(店名移到原距离的位置)。
|
||||
card.distance_text = None
|
||||
card.distance_meters = None
|
||||
cards.append(card)
|
||||
return FeedResponse(items=cards, has_next=has_next, page=req.page,
|
||||
status="ok" if cards else "empty")
|
||||
|
||||
# 默认(老客户端不传 tab):沿用逐轮分页的混合 feed,不筛。
|
||||
page_idx = req.page - 1
|
||||
if page_idx >= len(_TOPIC_ROUNDS):
|
||||
return FeedResponse(items=[], has_next=False, page=req.page)
|
||||
return FeedResponse(items=[], has_next=False, page=req.page, status="empty")
|
||||
|
||||
wm_topic, dd_topic = _TOPIC_ROUNDS[page_idx]
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
f_wm = pool.submit(_fetch_topic, 1, None, wm_topic)
|
||||
f_dd = pool.submit(_fetch_topic, 2, 1, dd_topic)
|
||||
waimai, daodian = f_wm.result(), f_dd.result()
|
||||
(waimai, wm_fail), (daodian, dd_fail) = f_wm.result(), f_dd.result()
|
||||
|
||||
items = _interleave(waimai, daodian)
|
||||
if tab == "rec":
|
||||
items = [c for c in items if _commission_pct(c) >= 3.0]
|
||||
has_next = page_idx + 1 < len(_TOPIC_ROUNDS)
|
||||
return FeedResponse(items=items, has_next=has_next, page=req.page)
|
||||
# 两路都失败且无数据 → degraded;否则有数据 ok / 无数据 empty
|
||||
status = "degraded" if (not items and wm_fail and dd_fail) else ("ok" if items else "empty")
|
||||
return FeedResponse(items=items, has_next=has_next, page=req.page, status=status)
|
||||
|
||||
|
||||
@router.post("/referral-link", response_model=ReferralLinkResponse, summary="换取推广链接(点抢时调)")
|
||||
@@ -172,32 +256,42 @@ def referral_link(req: ReferralLinkRequest) -> ReferralLinkResponse:
|
||||
@router.post("/top-sales", response_model=CouponListResponse,
|
||||
summary="销量最高(从离线库 meituan_coupon 按销量降序 + 跨源去重,不实时打美团)")
|
||||
def top_sales(req: TopSalesRequest, db: Session = Depends(get_db)) -> CouponListResponse:
|
||||
# 只取有销量档的(美团很多券没销量,排不了);按销量降序、同销量再按佣金降序
|
||||
stmt = select(MeituanCoupon).where(MeituanCoupon.sale_volume_num.isnot(None))
|
||||
if req.platform is not None:
|
||||
stmt = stmt.where(MeituanCoupon.platform == req.platform)
|
||||
stmt = stmt.order_by(
|
||||
MeituanCoupon.sale_volume_num.desc(),
|
||||
MeituanCoupon.commission_percent.desc(),
|
||||
)
|
||||
rows = db.execute(stmt).scalars().all()
|
||||
# 去重 + 排序 + 分页全在 SQL 做,每页只取并解析当前页 ~20 条。
|
||||
# (之前实现每翻一页都全表拉取 + 全量 from_raw 解析,翻页慢 → 客户端滑动卡顿/翻不动。)
|
||||
# 库为空(prod 刚部署 / ETL 未跑完)时返空 + status=empty,不崩;库查询异常降级 degraded。
|
||||
try:
|
||||
# 1) DISTINCT ON (dedup_key):每个去重键(品牌|名|价)只留销量最高那条(同销量再按佣金)
|
||||
base = select(MeituanCoupon).where(MeituanCoupon.sale_volume_num.isnot(None))
|
||||
if req.platform is not None:
|
||||
base = base.where(MeituanCoupon.platform == req.platform)
|
||||
deduped = base.distinct(MeituanCoupon.dedup_key).order_by(
|
||||
MeituanCoupon.dedup_key,
|
||||
MeituanCoupon.sale_volume_num.desc(),
|
||||
MeituanCoupon.commission_percent.desc(),
|
||||
).subquery()
|
||||
|
||||
# 跨源去重(dedup_key = 品牌|名|价):按销量降序遍历,每个 dedup_key 只留第一条(=销量最高那条)。
|
||||
# 用 raw(整条原始返回)重建 CouponCard,字段与实时接口完全一致,前端无需改渲染。
|
||||
seen: set[str] = set()
|
||||
# 2) 对去重结果按销量降序分页;多取 1 条判断 has_next,只对本页做 from_raw
|
||||
m = aliased(MeituanCoupon, deduped)
|
||||
start = (req.page - 1) * req.page_size
|
||||
rows = db.execute(
|
||||
select(m)
|
||||
# 加 id 作稳定 tiebreaker:同销量同佣金的并列项排序确定,避免跨页重复/漏项
|
||||
.order_by(m.sale_volume_num.desc(), m.commission_percent.desc(), m.id)
|
||||
.offset(start)
|
||||
.limit(req.page_size + 1)
|
||||
).scalars().all()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("[top-sales] 库查询失败,降级返空")
|
||||
return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded")
|
||||
|
||||
has_next = len(rows) > req.page_size
|
||||
cards: list[CouponCard] = []
|
||||
for row in rows:
|
||||
if row.dedup_key in seen:
|
||||
continue
|
||||
seen.add(row.dedup_key)
|
||||
for row in rows[:req.page_size]:
|
||||
try:
|
||||
card = CouponCard.from_raw(row.raw or {})
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
if card.product_view_sign:
|
||||
cards.append(card)
|
||||
|
||||
start = (req.page - 1) * req.page_size
|
||||
page_items = cards[start:start + req.page_size]
|
||||
has_next = start + req.page_size < len(cards)
|
||||
return CouponListResponse(items=page_items, has_next=has_next, search_id=None)
|
||||
return CouponListResponse(items=cards, has_next=has_next, search_id=None,
|
||||
status="ok" if cards else "empty")
|
||||
|
||||
+15
-1
@@ -16,9 +16,10 @@ from fastapi import APIRouter, File, HTTPException, UploadFile
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core import media
|
||||
from app.repositories import onboarding as onboarding_repo
|
||||
from app.repositories import user as user_repo
|
||||
from app.schemas.auth import UserOut
|
||||
from app.schemas.user import OkResponse, ProfileUpdateRequest
|
||||
from app.schemas.user import OkResponse, OnboardingCompleteRequest, ProfileUpdateRequest
|
||||
|
||||
logger = logging.getLogger("shagua.user")
|
||||
|
||||
@@ -51,6 +52,19 @@ async def upload_avatar(
|
||||
return UserOut.model_validate(updated)
|
||||
|
||||
|
||||
@router.post("/onboarding/complete", response_model=OkResponse, summary="标记新手引导完成(按 设备+账号)")
|
||||
def complete_onboarding(
|
||||
req: OnboardingCompleteRequest, user: CurrentUser, db: DbSession
|
||||
) -> OkResponse:
|
||||
"""走完新手引导时调一次。按 (当前账号, device_id) 落一条完成标记,跨卸载重装持久。
|
||||
幂等:重复调用不报错。device_id 取客户端硬件级 ANDROID_ID,与登录请求一致。"""
|
||||
onboarding_repo.mark_completed(db, user_id=user.id, device_id=req.device_id)
|
||||
# 若该用户被运营「一键开启新手引导」强制拉回引导,走完即清除强制标记(否则下次启动还会再触发)。
|
||||
user_repo.clear_force_onboarding(db, user)
|
||||
logger.info("onboarding complete user_id=%d device_len=%d", user.id, len(req.device_id))
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.delete("", response_model=OkResponse, summary="注销账号(软删除)")
|
||||
def delete_account(user: CurrentUser, db: DbSession) -> OkResponse:
|
||||
media.delete_avatar(user.avatar_url)
|
||||
|
||||
+17
-5
@@ -98,7 +98,11 @@ INVITE_FP_WINDOW_DAYS: int = 7
|
||||
|
||||
|
||||
# ===== 看激励视频 / 信息流广告发金币 =====
|
||||
# 金币数值体系约定:eCPM 单位按"元/千次展示"处理,单次收入 = eCPM / 1000 元。
|
||||
# eCPM 取自穿山甲 SDK getShowEcpm().getEcpm(),官方口径单位是【分/千次展示】(不是元!
|
||||
# csjplatform 文档原文"通过 getEcpm 获取的单位是分")。计算时先 ÷100 转成元;
|
||||
# 因子1 档位阈值按【元/千次】定(100/200/400 元 = ¥100/¥200/¥400 CPM,产品口径 2026-06-09)。
|
||||
# 注:真实 eCPM 一般 <¥100 CPM,故多落最低档 0.1,高档基本不触发——这是产品有意的取舍。
|
||||
# 单次展示收益(元) = eCPM元 ÷ 1000(每千次→单次)。
|
||||
AD_ECPM_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = (
|
||||
(0.1, 0, 100),
|
||||
(0.3, 101, 200),
|
||||
@@ -114,8 +118,8 @@ AD_LT_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = (
|
||||
)
|
||||
|
||||
|
||||
def parse_ecpm_yuan(ecpm: str | int | float | None) -> float:
|
||||
"""解析 eCPM 原始值。当前产品口径:SDK 返回值按"元/千次展示"处理。"""
|
||||
def parse_ecpm_fen(ecpm: str | int | float | None) -> float:
|
||||
"""解析 eCPM 原始值(穿山甲 getEcpm 原值,单位=分/千次展示)。非法/缺失→0。"""
|
||||
if ecpm is None:
|
||||
return 0.0
|
||||
try:
|
||||
@@ -125,8 +129,14 @@ def parse_ecpm_yuan(ecpm: str | int | float | None) -> float:
|
||||
return max(0.0, value)
|
||||
|
||||
|
||||
def parse_ecpm_yuan(ecpm: str | int | float | None) -> float:
|
||||
"""eCPM 转成元(getEcpm 原值是分,÷100)。因子档位判定与收益换算都用元。"""
|
||||
return parse_ecpm_fen(ecpm) / 100.0
|
||||
|
||||
|
||||
def ad_ecpm_factor(ecpm_yuan: float) -> float:
|
||||
"""eCPM 档位因子:0-100=0.1,101-200=0.3,201-400=0.4,>400=0.6。"""
|
||||
"""eCPM 档位因子(阈值单位=元/千次):≤100=0.1,101-200=0.3,201-400=0.4,>400=0.6。
|
||||
产品口径(2026-06-09):阈值按元判档;真实 eCPM(<¥100 CPM)多落最低档 0.1。"""
|
||||
if ecpm_yuan > 400:
|
||||
return 0.6
|
||||
if ecpm_yuan > 200:
|
||||
@@ -148,7 +158,9 @@ def ad_lt_factor(today_count_after_this: int) -> float:
|
||||
def calculate_ad_reward_coin(ecpm: str | int | float | None, today_count_after_this: int) -> int:
|
||||
"""按金币数值体系计算单份广告奖励金币。
|
||||
|
||||
单次奖励(元)=eCPM/1000 × 因子1(eCPM 档) × 因子2(LT);再按 1 元=10000 金币取整。
|
||||
eCPM 是穿山甲 getEcpm 原值,单位【分/千次展示】;先 ÷100 转成元(因子判档 + 收益换算都用元)。
|
||||
单次收益(元)= eCPM元 ÷ 1000(每千次→单次) × 因子1(eCPM 元档) × 因子2(LT);
|
||||
再按 1 元=10000 金币取整。
|
||||
"""
|
||||
ecpm_yuan = parse_ecpm_yuan(ecpm)
|
||||
yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(today_count_after_this)
|
||||
|
||||
@@ -9,12 +9,14 @@ from app.models.comparison import ComparisonRecord # noqa: F401
|
||||
from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401
|
||||
from app.models.coupon_state import ( # noqa: F401
|
||||
CouponClaimRecord,
|
||||
CouponDailyCompletion,
|
||||
CouponPromptEngagement,
|
||||
)
|
||||
from app.models.feedback import Feedback # noqa: F401
|
||||
from app.models.invite import InviteRelation # noqa: F401
|
||||
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
|
||||
from app.models.meituan_coupon import MeituanCoupon # noqa: F401
|
||||
from app.models.onboarding import OnboardingCompletion # noqa: F401
|
||||
from app.models.ops_marquee_seed import OpsMarqueeSeed # noqa: F401
|
||||
from app.models.ops_stat_config import OpsStatConfig # noqa: F401
|
||||
from app.models.price_observation import PriceObservation # noqa: F401
|
||||
|
||||
@@ -35,7 +35,7 @@ class AdEcpmRecord(Base):
|
||||
adn: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
# 实际展示用的代码位(底层 mediation rit,非客户端配置位)
|
||||
slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# 客户端上报的 eCPM 原始字符串(单位:元/千次展示,原样存)
|
||||
# 客户端上报的 eCPM 原始字符串(单位:分/千次展示,SDK getEcpm 原值,原样存)
|
||||
ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
# 北京时间日期串 'YYYY-MM-DD',按它等值做"按天聚合"(不在 SQL 里做跨时区 date 比较)
|
||||
report_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False)
|
||||
|
||||
@@ -19,6 +19,7 @@ from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
@@ -39,6 +40,9 @@ class ComparisonRecord(Base):
|
||||
__table_args__ = (
|
||||
# 同一用户同一次比价(trace_id)只存一条:客户端重试/误点重复上报时幂等覆盖。
|
||||
UniqueConstraint("user_id", "trace_id", name="uq_comparison_user_trace"),
|
||||
# 首页轮播 / 省钱战绩聚合都按 status='success' 过滤 + created_at 近期排序;
|
||||
# 复合索引避免随数据量增大退化成全表扫(单列 created_at 索引不含 status)。
|
||||
Index("ix_comparison_status_created", "status", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
@@ -90,7 +94,7 @@ class ComparisonRecord(Base):
|
||||
# ===== 明细(JSON,越详细越好)=====
|
||||
# 下单菜品 [{name, qty, specs?}]
|
||||
items: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
|
||||
# 逐平台对比 [{platform_id, platform_name, package, price, is_source, rank, coupon_saved, coupon_name}](price/coupon_saved 单位:元,原样存;coupon_name=优惠来源名)
|
||||
# 逐平台对比 [{platform_id, platform_name, package, price, is_source, rank, coupon_saved, coupon_name, applied_coupons}](price/coupon_saved 单位:元,原样存;coupon_name=优惠来源名;applied_coupons=[{name,amount}] 多券明细)
|
||||
comparison_results: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
|
||||
# 目标平台未找到、跳过的菜名
|
||||
skipped_dish_names: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
|
||||
|
||||
@@ -93,6 +93,49 @@ class CouponClaimRecord(Base):
|
||||
)
|
||||
|
||||
|
||||
class CouponDailyCompletion(Base):
|
||||
"""按 (device, 自然日) 记"今天是否已跑完整轮领券(到 done 帧)"——首页置灰源。
|
||||
|
||||
与 engagement 区别:engagement = 用户表达过意向(点了一键领取/拒绝即记,不管跑没跑完);
|
||||
completion = 这一轮真跑到了 done(整套流程走完)。首页「去领取」卡据此置灰:跑完了
|
||||
今天就不能再领。判断口径(用户决策 2026-06-10 A 方案):**到 done 即算**,不管单券
|
||||
成败(失败/跳过常是无障碍/环境问题,重复点也补不回来)。判断维度 device_id,与
|
||||
engagement/claim 一致。
|
||||
"""
|
||||
|
||||
__tablename__ = "coupon_daily_completion"
|
||||
__table_args__ = (
|
||||
# 一台设备一天一条:今天跑完过就置灰。
|
||||
UniqueConstraint(
|
||||
"device_id", "complete_date",
|
||||
name="uq_coupon_completion_device_date",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
|
||||
# Asia/Shanghai 自然日。
|
||||
complete_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
# 哪次任务跑到 done,回指 pricebot work_logs / 排查。
|
||||
trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
f"<CouponDailyCompletion device={self.device_id} "
|
||||
f"date={self.complete_date}>"
|
||||
)
|
||||
|
||||
|
||||
class CouponPromptEngagement(Base):
|
||||
"""按 (device, 自然日) 记"今天是否对领券引导窗表达过意向"——弹窗频控源。"""
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""新手引导完成标记(按 设备 + 账号 去重)。
|
||||
|
||||
产品规则:同一台设备 + 同一个账号,新手引导只跑一次;卸载重装(同设备同账号)
|
||||
不再触发。本地标记(SharedPreferences)卸载即丢,所以"完成"必须落后端,按
|
||||
(user_id, device_id) 唯一一条。
|
||||
|
||||
⚠️ device_id 这里用客户端的**硬件级稳定标识**(Android `Settings.Secure.ANDROID_ID`),
|
||||
同签名 app 卸载重装不变、仅恢复出厂才重置——区别于领券/比价用的 per-install device_id
|
||||
(见 coupon_state,存 SP、重装会变)。换新设备 → 无此行 → 重新走引导。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class OnboardingCompletion(Base):
|
||||
"""一台设备 + 一个账号 一条:走完新手引导即写入,登录时据此跳过引导。"""
|
||||
|
||||
__tablename__ = "onboarding_completion"
|
||||
__table_args__ = (
|
||||
# 同账号、同设备只一条:重复 mark / 并发提交靠它幂等。
|
||||
UniqueConstraint("user_id", "device_id", name="uq_onboarding_user_device"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
# 登录态用户。引导是登录后才走的,故必填(非空)、进唯一键。
|
||||
user_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
|
||||
# 硬件级稳定设备标识(ANDROID_ID),卸载重装不变。
|
||||
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
completed_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<OnboardingCompletion user={self.user_id} device={self.device_id}>"
|
||||
@@ -25,7 +25,8 @@ class OpsMarqueeSeed(Base):
|
||||
__tablename__ = "ops_marquee_seed"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
# 脱敏用户名,如「用户********a52」;留空(NULL)→ feed 展示时按脱敏格式随机合成(避开撞名)
|
||||
# 脱敏用户名(手机尾号 / 中文昵称风格,如「138****5678」「省钱**」);留空(NULL)或旧「用户****xxx」
|
||||
# 模板名 → feed 展示时随机合成混合风格名(避开撞名、自愈历史种子)
|
||||
masked_user: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# 节省金额区间(分);feed 每次在 [min,max] 随机取一个值。固定金额则 min==max。客户端 ÷100 显示「x.xx 元」
|
||||
min_cents: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
@@ -53,7 +53,8 @@ class OpsStatConfig(Base):
|
||||
random_kind: Mapped[str] = mapped_column(String(8), nullable=False, default="mult")
|
||||
random_step_min: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
random_step_max: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
# real 模式基数偏移(基础单位;total_saved 为分):展示值 = 真实值 + 偏移(冷启动期既真实又体面)
|
||||
# real 模式保底值(基础单位;total_saved 为分):展示值 = max(真实值, 保底值)。冷启动显示保底撑
|
||||
# 场面,真实值涨过保底后显示纯真实、零差距。列名沿用 real_offset(语义已从"偏移"改为"保底")
|
||||
real_offset: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
# 门面数字默认「只增不减」;运营明确要下调时才开此开关(real/manual 刷新都受护栏约束)
|
||||
allow_decrease: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
@@ -58,6 +58,13 @@ class User(Base):
|
||||
Boolean, nullable=False, default=False, server_default=false()
|
||||
)
|
||||
|
||||
# 运营后台「一键开启新手引导」:置 true 后,该用户下次启动 App(/me 与登录响应带出此字段)会被
|
||||
# 强制重走新手引导教程——即便本地早已标记完成。走完引导(/onboarding/complete)即自动清回 false,
|
||||
# 不会无限循环。默认 false。
|
||||
force_onboarding: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default=false()
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
@@ -13,7 +13,11 @@ from sqlalchemy import delete, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.coupon_state import CouponClaimRecord, CouponPromptEngagement
|
||||
from app.models.coupon_state import (
|
||||
CouponClaimRecord,
|
||||
CouponDailyCompletion,
|
||||
CouponPromptEngagement,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.coupon_state")
|
||||
|
||||
@@ -78,6 +82,47 @@ def reset_today_engagement(db: Session, device_id: str) -> int:
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
# ===== 今日跑完整轮(coupon_daily_completion)=====
|
||||
|
||||
def has_completed_today(db: Session, device_id: str) -> bool:
|
||||
"""这台设备今天是否已跑完整轮领券(到 done 帧)。有 = 首页置灰、不能再领。"""
|
||||
row = db.execute(
|
||||
select(CouponDailyCompletion.id).where(
|
||||
CouponDailyCompletion.device_id == device_id,
|
||||
CouponDailyCompletion.complete_date == today_cn(),
|
||||
)
|
||||
).first()
|
||||
return row is not None
|
||||
|
||||
|
||||
def mark_completed_today(
|
||||
db: Session, device_id: str, user_id: int | None, trace_id: str | None = None
|
||||
) -> None:
|
||||
"""记今日已跑完整轮。(device, 今天) 唯一,幂等 upsert。到 done 即记,不管单券成败。"""
|
||||
today = today_cn()
|
||||
row = db.execute(
|
||||
select(CouponDailyCompletion).where(
|
||||
CouponDailyCompletion.device_id == device_id,
|
||||
CouponDailyCompletion.complete_date == today,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is not None:
|
||||
if user_id is not None:
|
||||
row.user_id = user_id
|
||||
if trace_id is not None:
|
||||
row.trace_id = trace_id
|
||||
else:
|
||||
db.add(CouponDailyCompletion(
|
||||
device_id=device_id, user_id=user_id,
|
||||
complete_date=today, trace_id=trace_id,
|
||||
))
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
# 并发下另一请求刚插了同 (device, 日) → 唯一约束撞,回滚忽略(本就幂等)。
|
||||
db.rollback()
|
||||
|
||||
|
||||
# ===== 领券记录(coupon_claim_record)=====
|
||||
|
||||
def record_claims(
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""新手引导完成标记的读写(按 user_id + device_id 去重)。
|
||||
|
||||
登录时 [is_completed] 判断该 (账号, 设备) 是否走过引导;走完引导时 [mark_completed] 写一条。
|
||||
device_id 为空(老客户端 / 取不到 ANDROID_ID)一律按"未完成"处理,即照常走引导,不误跳过。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.onboarding import OnboardingCompletion
|
||||
|
||||
|
||||
def is_completed(db: Session, *, user_id: int, device_id: str) -> bool:
|
||||
"""该 (账号, 设备) 是否已完成新手引导。device_id 为空 → 一律 False。"""
|
||||
if not device_id:
|
||||
return False
|
||||
stmt = select(OnboardingCompletion.id).where(
|
||||
OnboardingCompletion.user_id == user_id,
|
||||
OnboardingCompletion.device_id == device_id,
|
||||
)
|
||||
return db.execute(stmt).first() is not None
|
||||
|
||||
|
||||
def mark_completed(db: Session, *, user_id: int, device_id: str) -> None:
|
||||
"""走完引导时写入。device_id 为空忽略;同 (账号, 设备) 已存在则幂等(靠唯一约束兜并发)。"""
|
||||
if not device_id:
|
||||
return
|
||||
db.add(OnboardingCompletion(user_id=user_id, device_id=device_id))
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
# 并发 / 重复提交撞唯一约束:已有行即视为成功。
|
||||
db.rollback()
|
||||
+158
-44
@@ -5,14 +5,17 @@ feed 取最近的成功且省到钱的比价记录(脱敏用户名);不足 N 条
|
||||
|
||||
种子是「生成规则」:用户名可空(空→随机合成脱敏名,避开同屏撞名)、金额是区间(每次随机取值)、
|
||||
feed 公平随机抽取(不看 sort_order),所有启用种子都有机会露出。
|
||||
|
||||
脱敏名风格:手机尾号(138****5678)+ 中文昵称(省钱**、小张**、吃货**达人)混合,看起来像真实
|
||||
异质用户群。真实条按 user_id 确定性合成(同用户恒定);种子留空 / 旧「用户****xxx」模板名一律随机
|
||||
合成(自愈历史种子,无需迁移)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.rewards import CN_TZ
|
||||
@@ -22,34 +25,70 @@ from app.models.ops_marquee_seed import OpsMarqueeSeed
|
||||
# feed 运行时随机源(每次请求结果不同 = 轮播想要的「鲜活感」)
|
||||
_rng = random.Random()
|
||||
|
||||
_SUFFIX_HEX = 3 # 脱敏用户名后缀位数,与真实条 md5[:3] 对齐
|
||||
_HEX = "0123456789abcdef"
|
||||
# 真实条金额上限(300 元):超过视为异常 / bug 值,不进轮播(防「节省 999 元」穿帮)
|
||||
_REAL_MAX_CENTS = 30000
|
||||
# 种子金额防呆上限(1000 元):运营手滑填超大值时拦下
|
||||
_SEED_MAX_CENTS = 100000
|
||||
|
||||
# 真实记录查询缓存:首页人人都看、真实数据变化慢 → 缓存原始行 ~30s,省掉高频 DB 往返。
|
||||
# 展示层随机(抽样/金额/时间/名字合成)仍每次重算,缓存只省查询;新记录最多晚 30s 进轮播,可接受。
|
||||
_REAL_ROWS_TTL_SECONDS = 30
|
||||
_REAL_ROWS_FETCH_CAP = 600 # 一次多取些,够 limit≤30 去重后取数;命中缓存后复用
|
||||
_real_rows_cache: dict = {"at": None, "rows": None}
|
||||
|
||||
# ===== 脱敏名生成:手机尾号 + 中文昵称 混合,看起来像真实异质用户群(替代旧的「用户********xxx」统一模板)=====
|
||||
_PHONE_2ND = "3456789" # 手机号第 2 位(13x~19x)
|
||||
_SURNAMES = "王李张刘陈杨黄赵周吴徐孙马朱胡郭何高林罗郑梁谢宋唐许韩冯邓曹彭曾肖田董袁潘蒋蔡余杜叶程苏魏吕丁任沈姚卢"
|
||||
_NICK_WORDS = [
|
||||
"省钱", "吃货", "羊毛", "薅羊毛", "爱比价", "精打细算", "持家", "干饭",
|
||||
"美食控", "外卖", "剁手", "捡漏", "搞钱", "打工人", "摸鱼", "养生",
|
||||
"佛系", "躺平", "暴富", "锦鲤", "夜宵", "奶茶", "热爱生活", "会过日子",
|
||||
]
|
||||
_NICK_PREFIX = ["小", "大", "老", "阿", "一只", "爱吃的", "快乐的"]
|
||||
_NICK_SUFFIX = ["达人", "小能手", "侠", "党", "一族", "星人", "鸭", "酱"]
|
||||
_LETTER_U = "ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||
_LETTER_L = "abcdefghijkmnpqrstuvwxyz"
|
||||
|
||||
|
||||
def _phone_name(rng: random.Random) -> str:
|
||||
"""手机尾号式:1[3-9]X****XXXX(合成、非真实号)。"""
|
||||
head = "1" + rng.choice(_PHONE_2ND) + str(rng.randint(0, 9))
|
||||
return f"{head}****{rng.randint(0, 9999):04d}"
|
||||
|
||||
|
||||
def _nickname(rng: random.Random) -> str:
|
||||
"""昵称打码式,中文为主(约 11/12),掺少量中英混 / 纯字母。"""
|
||||
p = rng.randint(0, 11)
|
||||
if p in (0, 1, 2):
|
||||
return rng.choice(_NICK_WORDS) + "**" # 省钱**
|
||||
if p in (3, 4):
|
||||
return rng.choice(_NICK_WORDS) + "**" + rng.choice(_NICK_SUFFIX) # 吃货**达人
|
||||
if p in (5, 6):
|
||||
return rng.choice(_SURNAMES) + "**" # 张**
|
||||
if p in (7, 8):
|
||||
return rng.choice(_NICK_PREFIX) + rng.choice(_SURNAMES) + "**" # 小张**
|
||||
if p in (9, 10):
|
||||
return rng.choice(_NICK_WORDS) + rng.choice(_LETTER_U) + "**" # 中英混 省钱A**
|
||||
return rng.choice(_LETTER_U) + "**" + rng.choice(_LETTER_L) # 纯字母(约 1/12)
|
||||
|
||||
|
||||
def _realistic_name(rng: random.Random) -> str:
|
||||
"""~45% 手机尾号 + ~55% 中文昵称,混合出真实异质感。"""
|
||||
return _phone_name(rng) if rng.random() < 0.45 else _nickname(rng)
|
||||
|
||||
|
||||
def _mask_user(user_id: int) -> str:
|
||||
"""真实用户脱敏:用户********+3 位哈希后缀(稳定、不暴露真实信息)。"""
|
||||
suffix = hashlib.md5(str(user_id).encode()).hexdigest()[:_SUFFIX_HEX]
|
||||
return f"用户********{suffix}"
|
||||
"""真实用户脱敏:按 user_id 确定性合成一个名(同用户恒定、不暴露真实信息)。"""
|
||||
return _realistic_name(random.Random(user_id))
|
||||
|
||||
|
||||
def _random_suffix(used: set[str]) -> str:
|
||||
"""随机 3 位 hex 后缀,避开 used(真实条 + 已生成种子),防同屏撞名。"""
|
||||
def _unique_name(used: set[str]) -> str:
|
||||
"""随机合成一个不与 used 撞的脱敏名(种子留空 / 旧模板名用),防同屏撞名。"""
|
||||
for _ in range(60):
|
||||
s = "".join(_rng.choice(_HEX) for _ in range(_SUFFIX_HEX))
|
||||
if s not in used:
|
||||
used.add(s)
|
||||
return s
|
||||
# 兜底(几乎不会到):线性扫一个空位
|
||||
for n in range(16 ** _SUFFIX_HEX):
|
||||
s = format(n, f"0{_SUFFIX_HEX}x")
|
||||
if s not in used:
|
||||
used.add(s)
|
||||
return s
|
||||
return "000"
|
||||
n = _realistic_name(_rng)
|
||||
if n not in used:
|
||||
return n
|
||||
return _realistic_name(_rng) + str(_rng.randint(0, 99)) # 兜底(几乎不会到)
|
||||
|
||||
|
||||
def _validate_amount(min_cents: int, max_cents: int) -> None:
|
||||
@@ -61,17 +100,30 @@ def _validate_amount(min_cents: int, max_cents: int) -> None:
|
||||
raise ValueError(f"金额上限过大(≤ {_SEED_MAX_CENTS // 100} 元)")
|
||||
|
||||
|
||||
# ===== 用户侧:轮播 feed(真实 + 种子混播) =====
|
||||
def get_feed(db: Session, limit: int = 8) -> list[dict]:
|
||||
"""返回最多 limit 条 {masked_user, saved_amount_cents, time(HH:MM:SS 北京)}。
|
||||
|
||||
真实条:success 且 0 < saved ≤ 上限,按 user 去重(同一用户只取最新一条,避免单人刷屏)。
|
||||
不足用启用的种子补齐——**公平随机抽取** need 个(而非固定取前 N),让所有种子都有机会露出;
|
||||
种子用户名留空则随机合成(避开撞名),金额在 [min,max] 区间随机取值。
|
||||
展示时间统一「刷新」成相对现在的最近时刻(从 now 往前递减),保证轮播永远像刚发生
|
||||
(真实用户/金额不变,只换展示时间——避免旧测试数据 / 低谷期记录显示成过时/未来时间)。
|
||||
def _skewed_amount(lo: int, hi: int) -> int:
|
||||
"""长尾金额:多数贴近下限、偶尔接近上限(比均匀随机更像真实省钱分布)。
|
||||
用 u**2.2 把 [0,1) 均匀值压向 0 → 小额居多;偶尔 u 接近 1 给出大额。
|
||||
"""
|
||||
# 真实条:取较多近期记录后按 user 去重;金额超上限的异常值剔除(防穿帮)。
|
||||
if hi <= lo:
|
||||
return max(0, lo)
|
||||
u = _rng.random() ** 2.2
|
||||
return lo + int(round((hi - lo) * u))
|
||||
|
||||
|
||||
# 兜底金额区间(真实 + 种子都凑不满 limit 时,内置合成条用):3~35 元
|
||||
_FALLBACK_MIN_CENTS = 300
|
||||
_FALLBACK_MAX_CENTS = 3500
|
||||
|
||||
|
||||
# ===== 用户侧:轮播 feed(真实 + 种子混播) =====
|
||||
def _recent_real_rows(db: Session) -> list[tuple[int, int]]:
|
||||
"""近期 success 且省到钱的 (user_id, saved_cents),按 created_at desc;带 ~30s 进程内缓存。
|
||||
返回纯元组(脱离 session),可安全跨请求复用。极端并发下偶尔多查一次(无锁、幂等),纯门面无副作用。
|
||||
"""
|
||||
now = datetime.now(CN_TZ)
|
||||
cached, at = _real_rows_cache["rows"], _real_rows_cache["at"]
|
||||
if cached is not None and at is not None and (now - at).total_seconds() < _REAL_ROWS_TTL_SECONDS:
|
||||
return cached
|
||||
rows = db.execute(
|
||||
select(ComparisonRecord.user_id, ComparisonRecord.saved_amount_cents)
|
||||
.where(
|
||||
@@ -80,18 +132,35 @@ def get_feed(db: Session, limit: int = 8) -> list[dict]:
|
||||
ComparisonRecord.saved_amount_cents <= _REAL_MAX_CENTS,
|
||||
)
|
||||
.order_by(ComparisonRecord.created_at.desc())
|
||||
.limit(max(limit * 20, 100))
|
||||
.limit(_REAL_ROWS_FETCH_CAP)
|
||||
).all()
|
||||
out = [(int(uid), int(sc)) for uid, sc in rows]
|
||||
_real_rows_cache["rows"], _real_rows_cache["at"] = out, now
|
||||
return out
|
||||
|
||||
|
||||
def get_feed(db: Session, limit: int = 8) -> list[dict]:
|
||||
"""返回最多 limit 条 {masked_user, saved_amount_cents, time(HH:MM:SS 北京)}。
|
||||
|
||||
真实条:success 且 0 < saved ≤ 上限,按 user 去重(同一用户只取最新一条,避免单人刷屏)。
|
||||
不足用启用的种子补齐——**公平随机抽取** need 个(而非固定取前 N),让所有种子都有机会露出;
|
||||
种子用户名留空则随机合成(避开撞名),金额取**长尾随机**(小额居多、偶尔大额,更像真实分布)。
|
||||
真实 + 种子仍不满 limit → 内置合成条**补满**,保证轮播既不空也不稀疏。
|
||||
展示时间统一「刷新」成相对现在的最近时刻(从 now 往前**随机抖动**递减),保证轮播永远像刚发生、
|
||||
节奏自然不机械(真实用户/金额不变,只换展示时间——避免旧测试数据 / 低谷期记录显示成过时时间)。
|
||||
"""
|
||||
# 真实条:取较多近期记录(带 ~30s 缓存)后按 user 去重;金额超上限的异常值已在查询剔除。
|
||||
rows = _recent_real_rows(db)
|
||||
|
||||
items: list[dict] = []
|
||||
used_suffixes: set[str] = set()
|
||||
used_names: set[str] = set()
|
||||
seen_users: set[int] = set()
|
||||
for uid, sc in rows:
|
||||
if uid in seen_users:
|
||||
continue
|
||||
seen_users.add(uid)
|
||||
name = _mask_user(uid)
|
||||
used_suffixes.add(name[-_SUFFIX_HEX:])
|
||||
used_names.add(name) # 真实名按 user_id 稳定;偶发撞名可接受
|
||||
items.append({"masked_user": name, "saved_amount_cents": int(sc)})
|
||||
if len(items) >= limit:
|
||||
break
|
||||
@@ -105,22 +174,41 @@ def get_feed(db: Session, limit: int = 8) -> list[dict]:
|
||||
chosen = _rng.sample(seeds, need) if len(seeds) > need else list(seeds)
|
||||
_rng.shuffle(chosen)
|
||||
for s in chosen:
|
||||
# 用户名:填了固定名优先;留空则按脱敏格式随机合成(避开撞名)。
|
||||
if s.masked_user and s.masked_user.strip():
|
||||
name = s.masked_user.strip()
|
||||
used_suffixes.add(name[-_SUFFIX_HEX:])
|
||||
else:
|
||||
name = f"用户********{_random_suffix(used_suffixes)}"
|
||||
# 金额:在 [min,max] 随机一个(固定金额则 min==max)。
|
||||
# 用户名:旧的「用户****xxx」统一模板名 / 留空 → 一律走新混合合成(避开同屏撞名、自愈历史种子);
|
||||
# 仅运营手动设的非模板真名才原样用。
|
||||
fixed = (s.masked_user or "").strip()
|
||||
name = fixed if fixed and not fixed.startswith("用户****") else _unique_name(used_names)
|
||||
used_names.add(name)
|
||||
# 金额:在 [min,max] 取长尾随机值(小额居多、偶尔大额;固定金额则 min==max)。
|
||||
lo = max(0, int(s.min_cents))
|
||||
hi = max(lo, int(s.max_cents))
|
||||
cents = lo if lo >= hi else _rng.randint(lo, hi)
|
||||
items.append({"masked_user": name, "saved_amount_cents": cents})
|
||||
items.append({"masked_user": name, "saved_amount_cents": _skewed_amount(lo, hi)})
|
||||
|
||||
# 统一赋「最近」时间:首条约 20 秒前,其余每条往前 2~6 分钟,降序、节奏自然。
|
||||
# 兜底:真实 + 种子仍凑不满 limit(种子被全停用 / 数量太少)→ 用内置合成补满,
|
||||
# 保证轮播既不空也不稀疏(稀疏的几条循环同样像假)。
|
||||
while len(items) < limit:
|
||||
name = _unique_name(used_names)
|
||||
used_names.add(name)
|
||||
items.append({
|
||||
"masked_user": name,
|
||||
"saved_amount_cents": _skewed_amount(_FALLBACK_MIN_CENTS, _FALLBACK_MAX_CENTS),
|
||||
})
|
||||
|
||||
# 统一赋「最近」时间:从 now 往前**随机抖动**递减,避免固定节奏被看出规律。
|
||||
# 首条几十秒前;其余多数 1~5 分钟,偶尔扎堆(20~55s)或较长(5~9 分钟),降序、像真实流水。
|
||||
cursor = datetime.now(CN_TZ)
|
||||
for i, it in enumerate(items):
|
||||
cursor = cursor - timedelta(seconds=20 if i == 0 else 60 * (2 + (i * 3) % 5))
|
||||
if i == 0:
|
||||
gap = _rng.randint(8, 40)
|
||||
else:
|
||||
r = _rng.random()
|
||||
if r < 0.2:
|
||||
gap = _rng.randint(20, 55) # 偶尔扎堆
|
||||
elif r < 0.85:
|
||||
gap = _rng.randint(60, 300) # 常规 1~5 分钟
|
||||
else:
|
||||
gap = _rng.randint(300, 540) # 偶尔较长 5~9 分钟
|
||||
cursor = cursor - timedelta(seconds=gap)
|
||||
it["time"] = cursor.strftime("%H:%M:%S")
|
||||
return items
|
||||
|
||||
@@ -244,3 +332,29 @@ def delete_seed(db: Session, seed_id: int, *, commit: bool = True) -> bool:
|
||||
else:
|
||||
db.flush()
|
||||
return True
|
||||
|
||||
|
||||
def batch_delete(db: Session, ids: list[int], *, commit: bool = True) -> int:
|
||||
"""批量删除选中的种子,返回实际删除条数(不存在的 id 自动跳过)。"""
|
||||
if not ids:
|
||||
return 0
|
||||
n = db.execute(delete(OpsMarqueeSeed).where(OpsMarqueeSeed.id.in_(ids))).rowcount
|
||||
if commit:
|
||||
db.commit()
|
||||
else:
|
||||
db.flush()
|
||||
return int(n or 0)
|
||||
|
||||
|
||||
def batch_set_enabled(db: Session, ids: list[int], enabled: bool, *, commit: bool = True) -> int:
|
||||
"""批量启用 / 停用选中的种子,返回实际更新条数。"""
|
||||
if not ids:
|
||||
return 0
|
||||
n = db.execute(
|
||||
update(OpsMarqueeSeed).where(OpsMarqueeSeed.id.in_(ids)).values(enabled=enabled)
|
||||
).rowcount
|
||||
if commit:
|
||||
db.commit()
|
||||
else:
|
||||
db.flush()
|
||||
return int(n or 0)
|
||||
|
||||
@@ -7,7 +7,8 @@ random 自增长支持两种方式(random_kind):
|
||||
- mult:×随机倍率([min,max]/1000,恒 ≥1.0)——复利、指数增长
|
||||
- add :+随机绝对增量([step_min,step_max])——线性、可控,更像真实平台日增
|
||||
|
||||
真实值(real)模式展示 = 真实统计 + real_offset(基数偏移),冷启动期既真实又体面。
|
||||
真实值(real)模式展示 = max(真实统计, real_offset 保底值):冷启动显示保底撑场面,真实值涨过
|
||||
保底后显示纯真实、零差距(real_offset 列名沿用,语义从"偏移"改为"保底")。
|
||||
|
||||
「只增不减」护栏(allow_decrease=False,默认):real/manual 刷新时若新目标值低于当前展示值,
|
||||
保持当前值不回退——门面数字最忌当众缩水。random 天然只增(倍率≥1 / 增量≥0)。
|
||||
@@ -116,8 +117,9 @@ def _current_target(db: Session, row: OpsStatConfig) -> int:
|
||||
if row.mode == "manual":
|
||||
return max(0, int(row.manual_value or 0))
|
||||
if row.mode == "real":
|
||||
# 真实值 + 基数偏移
|
||||
return max(0, int(_real_value(db, row.metric)) + int(row.real_offset or 0))
|
||||
# 真实值保底:展示 = max(真实值, 保底值)。冷启动真实值小→显示保底撑场面;
|
||||
# 真实值涨过保底后显示纯真实、零差距(real_offset 列名沿用,语义已从"偏移"改为"保底")。
|
||||
return max(0, int(_real_value(db, row.metric)), int(row.real_offset or 0))
|
||||
# random:无显式初始基数时用真实值(纯真实,不含偏移)播种
|
||||
return max(0, int(_real_value(db, row.metric)))
|
||||
|
||||
@@ -302,7 +304,7 @@ def update_config(
|
||||
raise ValueError("增量上限不得小于下限")
|
||||
if real_offset is not None:
|
||||
if real_offset < 0:
|
||||
raise ValueError("基数偏移需为非负整数")
|
||||
raise ValueError("保底值需为非负整数")
|
||||
row.real_offset = real_offset
|
||||
if allow_decrease is not None:
|
||||
row.allow_decrease = allow_decrease
|
||||
@@ -311,7 +313,9 @@ def update_config(
|
||||
if random_initial is not None:
|
||||
if random_initial < 0:
|
||||
raise ValueError("初始基数需为非负整数")
|
||||
row.random_current = random_initial # 设起点是运营明确意图,不受护栏约束
|
||||
# 初始基数受「只增不减」护栏(与 manual/real 一致):未勾 allow_decrease 且低于
|
||||
# 当前展示值时保持现值。首次无值(random_current is None)则护栏放行、直接设。
|
||||
row.random_current = _monotonic(row, random_initial)
|
||||
row.random_last_tick_at = now
|
||||
elif apply_now:
|
||||
# 立即更新:不等钟点,马上刷新一次
|
||||
|
||||
@@ -62,6 +62,16 @@ def set_avatar_url(db: Session, user: User, *, avatar_url: str) -> User:
|
||||
return user
|
||||
|
||||
|
||||
def clear_force_onboarding(db: Session, user: User) -> None:
|
||||
"""用户走完(被运营强制开启的)新手引导后,清除强制标记,避免下次启动再被拉回引导。
|
||||
|
||||
幂等:未置位时直接返回,不空 commit。由 /onboarding/complete 在标记完成后调用。
|
||||
"""
|
||||
if user.force_onboarding:
|
||||
user.force_onboarding = False
|
||||
db.commit()
|
||||
|
||||
|
||||
def soft_delete_account(db: Session, user: User) -> None:
|
||||
"""注销账号:软删除 + 匿名化。
|
||||
|
||||
|
||||
@@ -129,6 +129,37 @@ def grant_coins(
|
||||
return acc, txn
|
||||
|
||||
|
||||
def grant_cash(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
amount_cents: int,
|
||||
*,
|
||||
biz_type: str,
|
||||
ref_id: str | None = None,
|
||||
remark: str | None = None,
|
||||
) -> tuple[CoinAccount, CashTransaction]:
|
||||
"""现金变动入口(正数入账 / 负数出账)。更新现金余额 + 写流水,不 commit。
|
||||
|
||||
与 [grant_coins] 同模式(运营手动调现金 / 测试发现金用)。返回 (account, transaction),
|
||||
调用方负责 commit。不在此校验扣成负——由调用方(admin router)按业务保护。
|
||||
"""
|
||||
acc = get_or_create_account(db, user_id, commit=False)
|
||||
acc.cash_balance_cents += amount_cents
|
||||
|
||||
txn = CashTransaction(
|
||||
user_id=user_id,
|
||||
amount_cents=amount_cents,
|
||||
balance_after_cents=acc.cash_balance_cents,
|
||||
biz_type=biz_type,
|
||||
ref_id=ref_id,
|
||||
remark=remark,
|
||||
created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None), # 存北京 wall-clock
|
||||
)
|
||||
db.add(txn)
|
||||
db.flush()
|
||||
return acc, txn
|
||||
|
||||
|
||||
def list_coin_transactions(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
|
||||
+8
-3
@@ -46,11 +46,11 @@ class AdRewardStatusOut(BaseModel):
|
||||
class EcpmReportIn(BaseModel):
|
||||
"""客户端上报一次广告展示的 eCPM(内部收益统计/对账)。
|
||||
|
||||
user_id 不在 body 里——由 JWT 取(Bearer),防伪造。ecpm 原样上报字符串,后端按元/千次展示处理。
|
||||
user_id 不在 body 里——由 JWT 取(Bearer),防伪造。ecpm 原样上报字符串,后端按分/千次展示处理(SDK getEcpm 原值,非元)。
|
||||
"""
|
||||
|
||||
ad_type: str = Field(..., description="广告类型:reward_video(激励视频) / draw(Draw 信息流) 等")
|
||||
ecpm: str = Field(..., description="穿山甲 getShowEcpm().getEcpm() 原始字符串,按元/千次展示处理")
|
||||
ecpm: str = Field(..., description="穿山甲 getShowEcpm().getEcpm() 原始字符串,按分/千次展示处理(SDK getEcpm 原值,非元)")
|
||||
ad_session_id: str | None = Field(
|
||||
None, min_length=8, max_length=64,
|
||||
description="客户端生成的一次广告会话 id;激励视频 S2S extra 会透传同值",
|
||||
@@ -89,6 +89,11 @@ class TestGrantIn(BaseModel):
|
||||
"reward_video",
|
||||
description="模拟发奖场景:reward_video(普通激励视频) / signin_boost(签到膨胀)",
|
||||
)
|
||||
ad_session_id: str | None = Field(
|
||||
None, min_length=8, max_length=64,
|
||||
description="本次广告会话 id(与 ecpm-report 同值)。reward_video 场景下据此查回客户端"
|
||||
"已上报的真实 eCPM 来按公式发奖;查不到或 eCPM≤0 时兜底 200,保证本地联调仍出非零金币",
|
||||
)
|
||||
|
||||
|
||||
class TestGrantOut(BaseModel):
|
||||
@@ -119,7 +124,7 @@ class FeedRewardIn(BaseModel):
|
||||
ad_session_id: str | None = Field(
|
||||
None, min_length=8, max_length=64, description="客户端生成的一次信息流广告会话 id"
|
||||
)
|
||||
ecpm: str = Field(..., description="本条信息流广告 eCPM,按元/千次展示处理")
|
||||
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="实际展示代码位")
|
||||
|
||||
@@ -27,6 +27,9 @@ class UserOut(BaseModel):
|
||||
last_login_at: datetime
|
||||
# 调试链接权限:前端据此在比价结果弹窗/记录页显示「复制调试链接」按钮。默认 false。
|
||||
debug_trace_enabled: bool = False
|
||||
# 运营后台「一键开启新手引导」:true → 客户端下次启动强制重走引导(即便本地已完成);
|
||||
# 走完引导后端自动清回 false。默认 false 兼容老客户端。
|
||||
force_onboarding: bool = False
|
||||
|
||||
|
||||
# ===== Token 通用结构 =====
|
||||
@@ -41,6 +44,11 @@ class TokenPair(BaseModel):
|
||||
|
||||
class TokenWithUser(TokenPair):
|
||||
user: UserOut
|
||||
onboarding_completed: bool = Field(
|
||||
False,
|
||||
description="该 设备+账号 是否已走完新手引导(据登录请求里的 device_id 计算)。"
|
||||
"true → 客户端登录后直接进首页,跳过引导。",
|
||||
)
|
||||
|
||||
|
||||
# ===== 极光一键登录 =====
|
||||
@@ -48,6 +56,10 @@ class TokenWithUser(TokenPair):
|
||||
class JverifyLoginRequest(BaseModel):
|
||||
login_token: str = Field(..., description="客户端 loginAuth 拿到的 loginToken", min_length=1)
|
||||
operator: str = Field("", description="CM/CU/CT,用于日志,可选")
|
||||
device_id: str = Field(
|
||||
"", max_length=64,
|
||||
description="硬件级设备标识(Android ANDROID_ID),用于新手引导按 设备+账号 去重;空=按未完成处理",
|
||||
)
|
||||
|
||||
|
||||
# ===== 短信验证码 =====
|
||||
@@ -65,6 +77,10 @@ class SmsSendResponse(BaseModel):
|
||||
class SmsLoginRequest(BaseModel):
|
||||
phone: str = Field(..., min_length=11, max_length=11, pattern=r"^1\d{10}$")
|
||||
code: str = Field(..., min_length=4, max_length=8)
|
||||
device_id: str = Field(
|
||||
"", max_length=64,
|
||||
description="硬件级设备标识(Android ANDROID_ID),用于新手引导按 设备+账号 去重;空=按未完成处理",
|
||||
)
|
||||
|
||||
|
||||
# ===== Refresh =====
|
||||
|
||||
@@ -24,6 +24,13 @@ class ComparisonItemIn(BaseModel):
|
||||
specs: list[str] | None = None
|
||||
|
||||
|
||||
class AppliedCouponIn(BaseModel):
|
||||
"""单笔已用优惠(来自 comparison_results[].applied_coupons)。amount 单位:元、正数。"""
|
||||
|
||||
name: str
|
||||
amount: float
|
||||
|
||||
|
||||
class ComparisonResultIn(BaseModel):
|
||||
"""逐平台对比项(来自 done.params.comparison_results)。price 单位:元。"""
|
||||
|
||||
@@ -40,6 +47,10 @@ class ComparisonResultIn(BaseModel):
|
||||
# 优惠**来源名**(展示用, best-effort): 美团"外卖大额神券"/京东"百亿补贴"/淘宝"平台红包"。
|
||||
# None=没抠到 → 前端走通用"红包"。同样必须显式声明否则上报边界被 pydantic 静默丢弃(pricebot#38 引入)。
|
||||
coupon_name: str | None = None
|
||||
# 多券明细 [{name, amount}](全口径: 平台红包+商家券+满减+配送减免, amount 单位元正数)。
|
||||
# 跟 coupon_saved 并存, 是更丰富的明细; 空=没抠到 → 前端回退单券路径。
|
||||
# 必须显式声明: 落库走 model_dump(), pydantic 默认丢未知字段, 不声明这行会被悄悄吞掉。
|
||||
applied_coupons: list[AppliedCouponIn] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ComparisonRecordIn(BaseModel):
|
||||
|
||||
@@ -19,3 +19,9 @@ class CouponPromptShouldShowOut(BaseModel):
|
||||
"""切到外卖 App 时是否还应弹领券引导窗。今天已 engage(领或拒)过 → false。"""
|
||||
|
||||
should_show: bool
|
||||
|
||||
|
||||
class CouponCompletedTodayOut(BaseModel):
|
||||
"""这台设备今天是否已跑完整轮领券(到 done 帧)。完成 → 首页「去领取」卡置灰。"""
|
||||
|
||||
completed: bool
|
||||
|
||||
+10
-1
@@ -1,10 +1,17 @@
|
||||
"""美团 CPS 券列表 / 换链相关 schemas。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# feed / 列表类接口的结果状态,供前端区分占位文案:
|
||||
# ok = 正常有数据
|
||||
# empty = 后端正常、查询成功但确实没有数据(显示「暂无优惠券」)
|
||||
# degraded = 上游(美团)或库查询失败,已降级返空(显示「服务繁忙,下拉重试」)
|
||||
# 老客户端不读该字段,默认 ok,向后兼容。
|
||||
FeedStatus = Literal["ok", "empty", "degraded"]
|
||||
|
||||
|
||||
# ───────────────── 券卡片(归一化后给客户端) ─────────────────
|
||||
|
||||
@@ -127,6 +134,7 @@ class CouponListResponse(BaseModel):
|
||||
items: list[CouponCard]
|
||||
has_next: bool = False
|
||||
search_id: str | None = None
|
||||
status: FeedStatus = Field("ok", description="ok 有数据 / empty 暂无 / degraded 上游失败已降级")
|
||||
|
||||
|
||||
class FeedRequest(BaseModel):
|
||||
@@ -146,6 +154,7 @@ class FeedResponse(BaseModel):
|
||||
items: list[CouponCard]
|
||||
has_next: bool = False
|
||||
page: int = 1
|
||||
status: FeedStatus = Field("ok", description="ok 有数据 / empty 暂无 / degraded 上游失败已降级")
|
||||
|
||||
|
||||
class TopSalesRequest(BaseModel):
|
||||
|
||||
@@ -15,7 +15,7 @@ class PlatformStatsOut(BaseModel):
|
||||
class SavingsFeedItem(BaseModel):
|
||||
"""首页轮播一条。time 为北京时间 HH:MM:SS。"""
|
||||
|
||||
masked_user: str # 脱敏用户名,如「用户********a52」
|
||||
masked_user: str # 脱敏用户名,手机尾号(138****5678)或中文昵称(省钱**)混合风格
|
||||
saved_amount_cents: int # 节省(分),客户端 ÷100 显示「x.xx 元」
|
||||
time: str
|
||||
|
||||
|
||||
@@ -16,5 +16,12 @@ class ProfileUpdateRequest(BaseModel):
|
||||
return v
|
||||
|
||||
|
||||
class OnboardingCompleteRequest(BaseModel):
|
||||
device_id: str = Field(
|
||||
..., min_length=1, max_length=64,
|
||||
description="硬件级设备标识(Android ANDROID_ID),与登录时一致,用于按 设备+账号 标记引导完成",
|
||||
)
|
||||
|
||||
|
||||
class OkResponse(BaseModel):
|
||||
ok: bool = True
|
||||
|
||||
+27
-15
@@ -62,11 +62,11 @@
|
||||
<div class="feat">💰 <span>省下的钱看得见,还能<b>赚金币提现</b></span></div>
|
||||
</div>
|
||||
|
||||
<button class="btn" id="dlbtn">⬇️ 下载安装包</button>
|
||||
<div class="hint" id="hint">Android 安卓版 · 安装包约 24 MB</div>
|
||||
<button class="btn" id="dlbtn">🏪 打开应用商店下载</button>
|
||||
<div class="hint" id="hint">Android 安卓版 · 应用商店安全下载</div>
|
||||
|
||||
<div class="foot">
|
||||
安装时如提示「未知来源」,请允许后继续安装。<br>
|
||||
将前往应用商店下载,安全放心。<br>
|
||||
本页为内部测试页。
|
||||
</div>
|
||||
|
||||
@@ -82,16 +82,18 @@
|
||||
<div class="wxsteps">
|
||||
<div><span class="n">1</span>点右上角的 ··· 菜单</div>
|
||||
<div><span class="n">2</span>选择「在浏览器打开」</div>
|
||||
<div><span class="n">3</span>在浏览器里点「下载安装包」</div>
|
||||
<div><span class="n">3</span>在浏览器里按提示去应用商店下载</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="closebar" id="wxclose">我知道了 ✕</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// APK 下载地址跟随当前页面 origin:本地测试时页面由笔记本 app-server(LAN:8770)托管 →
|
||||
// 下载也走 LAN;生产由 app-api.shaguabijia.com 托管 → 下载走生产域名。无需按机器改 IP。
|
||||
var APK_URL = location.origin + "/media/shaguabijia.apk";
|
||||
// 应用商店跳转:用包名(App 唯一标识 = 身份证号)定位到傻瓜比价的商店下载页。
|
||||
// 主:market:// 唤起手机自带应用市场(华为/小米/OV);兜底:应用宝网页(任何浏览器都能开)。
|
||||
var PKG = "com.jishisongfu.shaguabijia";
|
||||
var MARKET_URL = "market://details?id=" + PKG;
|
||||
var YYB_URL = "https://a.app.qq.com/o/simple.jsp?pkgname=" + PKG;
|
||||
var ua = navigator.userAgent || "";
|
||||
var isWeChat = /MicroMessenger/i.test(ua);
|
||||
var isIOS = /iPhone|iPad|iPod/i.test(ua);
|
||||
@@ -138,12 +140,20 @@
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
// 确保剪贴板写完(或最多等 400ms 兜底,防写入卡住)再触发下载。
|
||||
function startDownload() {
|
||||
var fired = false;
|
||||
function go() { if (!fired) { fired = true; window.location.href = APK_URL; } }
|
||||
copyInviteCode().then(go);
|
||||
setTimeout(go, 400);
|
||||
// 跳应用商店:先尝试 market:// 唤起手机自带商店;若 2.5s 内页面没切到后台
|
||||
//(= 没有商店接管 market://),兜底跳应用宝网页下载页,不让用户卡死。
|
||||
function openStore() {
|
||||
var jumped = false;
|
||||
function markJumped() { jumped = true; } // 页面切到后台 = 商店已唤起
|
||||
document.addEventListener("visibilitychange", function () {
|
||||
if (document.hidden) jumped = true;
|
||||
});
|
||||
window.addEventListener("pagehide", markJumped);
|
||||
window.addEventListener("blur", markJumped);
|
||||
window.location.href = MARKET_URL; // 唤起自带应用市场
|
||||
setTimeout(function () {
|
||||
if (!jumped) window.location.href = YYB_URL; // 没唤起 → 应用宝网页兜底
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
var hint = document.getElementById("hint");
|
||||
@@ -160,8 +170,10 @@
|
||||
document.getElementById("dlbtn").addEventListener("click", function(){
|
||||
if (isWeChat) { showMask(); return; } // 微信内:引导去浏览器
|
||||
if (isIOS) { alert("iOS 版即将上线,请前往 App Store 搜索「傻瓜比价」"); return; }
|
||||
// 普通浏览器:先把邀请码写进剪贴板(写完或 400ms 兜底),再下载 APK(首启读回完成归因)
|
||||
startDownload();
|
||||
// 安卓浏览器:先把邀请码写进剪贴板(legacyCopy 同步写、最可靠),再跳应用商店。
|
||||
// 剪贴板供 App 首启归因;链路长易丢时由指纹兜底(已上报 landing-track)接住。
|
||||
copyInviteCode();
|
||||
openStore();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# 美团券 ETL 定时任务 — 运维手册
|
||||
|
||||
> 对象:维护「美团 CPS 券抓取入库」这套定时任务的同事。
|
||||
> 🔒 登录信息(服务器 IP / 账号密码 / DB 密码)见**私密交接清单**,**不入库**。
|
||||
> 运行账号 `cps` **无 sudo**,本手册所有操作都不需要 root。
|
||||
|
||||
## 它是什么
|
||||
每小时全量抓美团 CPS 券(北京)→ upsert 进 PostgreSQL 的 `meituan_coupon` 表,作为 App「销量最高 / 智能推荐」两个 tab 的数据源。一轮约 140 秒、~2700 条;靠 `cps` 用户的 crontab 常驻,关终端也照跑。
|
||||
|
||||
## 代码 / 文件在哪(都在生产服务器上)
|
||||
| 项 | 路径 |
|
||||
|---|---|
|
||||
| 代码目录 | `/opt/shaguabijia-app-server` |
|
||||
| 抓取脚本 | `scripts/pull_meituan_coupons.py` |
|
||||
| Python(venv) | `/opt/shaguabijia-app-server/.venv/bin/python` |
|
||||
| 配置(美团凭证 / DB 连接串) | `/opt/shaguabijia-app-server/.env`(`MT_CPS_*` / `DATABASE_URL`)|
|
||||
| 日志 | `/home/cps/meituan_etl.log` |
|
||||
| 运行锁 | `/tmp/meituan_etl.lock`(根治版)或 `~/data/.meituan_etl.lock`(过渡版,见文末)|
|
||||
| 定时配置 | `cps` 用户 crontab(`crontab -l`)|
|
||||
| 入库表 | PostgreSQL `shaguabijia` 库的 `meituan_coupon` |
|
||||
|
||||
## 怎么看在不在跑 / 健康
|
||||
```bash
|
||||
crontab -l # 看定时任务(应有每小时第 17 分那条)
|
||||
tail -n 20 ~/meituan_etl.log # 看最近几轮:本轮完成 / 入库条数 / 有无报错
|
||||
# 查库存量与新鲜度(走 .env 连接串,不用输 DB 密码):
|
||||
cd /opt/shaguabijia-app-server && .venv/bin/python -c "from sqlalchemy import text; from app.db.session import engine; print(engine.connect().execute(text('select count(*) total, count(*) filter (where sale_volume_num is not null) sales, max(last_seen) last_run from meituan_coupon')).one())"
|
||||
```
|
||||
**健康标准**:`last_run` 在最近 1–2 小时内、`total` 两三千、日志最近一轮是 `本轮完成`(不是 `Permission denied` / Traceback)。
|
||||
|
||||
## 怎么启动
|
||||
定时任务已在 `cps` 的 crontab 里常驻,**随系统 cron 服务自动运行,无需手动启动**。若 crontab 被清空、要重新装回:`crontab -e`,粘下面这行存盘。
|
||||
|
||||
```cron
|
||||
# 根治版(锁文件已改到 /tmp 后用这条,推荐):
|
||||
17 * * * * cd /opt/shaguabijia-app-server && .venv/bin/python -m scripts.pull_meituan_coupons --once --prune-hours 24 >> /home/cps/meituan_etl.log 2>&1
|
||||
```
|
||||
|
||||
**手动立即跑一轮**(不等下一个整点,比如刚发现库空):
|
||||
```bash
|
||||
cd /opt/shaguabijia-app-server && .venv/bin/python -m scripts.pull_meituan_coupons --once --prune-hours 24
|
||||
```
|
||||
前台跑、约 140 秒,出现 `本轮完成` 即成功。
|
||||
|
||||
## 怎么关闭(停用定时任务)
|
||||
```bash
|
||||
crontab -e
|
||||
```
|
||||
在那一行最前面加 `#` 注释掉(**推荐**,保留备查),存盘即停;要彻底删就删那一行。
|
||||
> ⚠️ 别用 `crontab -r`——它会**清空你所有 cron**,不只这一条。
|
||||
|
||||
## 怎么终止(杀掉正在跑的那一轮)
|
||||
```bash
|
||||
pgrep -af pull_meituan_coupons # 查在跑的进程和 PID
|
||||
pkill -f pull_meituan_coupons # 杀掉它(或 kill <PID>)
|
||||
rm -f /tmp/meituan_etl.lock ~/data/.meituan_etl.lock # 清掉残留锁,否则下一轮可能"跳过本轮"
|
||||
```
|
||||
> 脚本有 **30 分钟陈旧锁自动接管**机制:即使没手动清锁,30 分钟后下一轮也会自愈;急着立刻重跑才需手动 `rm` 锁。
|
||||
|
||||
## 调整频率
|
||||
`crontab -e` 改那行最前面的 cron 表达式:
|
||||
| 频率 | 表达式 |
|
||||
|---|---|
|
||||
| 每小时第 17 分(当前) | `17 * * * *` |
|
||||
| 每 30 分钟 | `*/30 * * * *` |
|
||||
| 每 2 小时 | `17 */2 * * *` |
|
||||
> ⚠️ 别调太密:一轮 ~140 秒,且要守美团限流(脚本已配速到 ~2–3 req/s,远低于 ~15 req/s 红线;402 内置退避重试)。**每小时一轮足够**,勿并行、勿调小页间 sleep。
|
||||
|
||||
## 脚本参数
|
||||
- `--once`:跑一轮(定时任务用这个)
|
||||
- `--loop --interval 600`:常驻循环,每 600 秒一轮(调试用)
|
||||
- `--prune-hours N`:清理超过 N 小时没再出现的旧券(默认 24;`0`=不清)
|
||||
- 锁文件位置可用环境变量 `MEITUAN_ETL_LOCK=/path/to.lock` 覆盖
|
||||
> 详见脚本头部注释:`head -30 scripts/pull_meituan_coupons.py`
|
||||
|
||||
## 已知问题与根治(运行锁权限)
|
||||
- **现象**:cron 报 `PermissionError: [Errno 13] ... 'data/.meituan_etl.lock'`,每轮抓不进数据、表为空。
|
||||
- **根因**:旧版脚本把运行锁建在代码目录的 `data/` 下,而 `data/` 属 root、`cps` 不可写(交接清单里"已给写权限"实际不成立)。
|
||||
- **根治(已在代码层修复)**:脚本把锁默认改到 `/tmp/meituan_etl.lock`(任何账号可写),并支持 `MEITUAN_ETL_LOCK` 覆盖。**部署该版本后,用上面「根治版」那条干净 crontab 即可。**
|
||||
- **过渡写法(根治版部署前的临时方案)**:crontab 里 `cd /home/cps` 把锁落到家目录、再用 `PYTHONPATH` 让代码仍能 import:
|
||||
```cron
|
||||
17 * * * * cd /home/cps && PYTHONPATH=/opt/shaguabijia-app-server /opt/shaguabijia-app-server/.venv/bin/python -m scripts.pull_meituan_coupons --once --prune-hours 24 >> /home/cps/meituan_etl.log 2>&1
|
||||
```
|
||||
根治版上线后,把这条换回上面的「根治版」标准写法即可。
|
||||
|
||||
## 注意事项
|
||||
- **只抓北京**:脚本里 `city_id` 硬编码,扩城市需改脚本。
|
||||
- **表行数 > 单轮抓取数**属正常:美团 `product_view_sign` 会轮换,跨轮累积;查询侧有 `DISTINCT ON(dedup_key)` 按「品牌\|名\|价」去重,用户看到的不重复;旧券 24h 后被 prune 清掉。
|
||||
- **别和 systemd 双跑**:仓库 `deploy/meituan-etl.{service,timer}` 是**未来由 root 纳入 systemd 正规部署**的备选,和本 user cron **二选一、别同时开**(否则重复抓、浪费限流额度)。当前生产用 user cron。
|
||||
- **改脚本 / 改部署**:走 git + PR,让有 root 的人 `/opt/deploy.sh` 部署(`git reset` + `alembic upgrade` + 重启);`cps` 账号改不了 `/opt` 下的代码。
|
||||
@@ -0,0 +1,33 @@
|
||||
# 美团 CPS 券 ETL —— 单轮抓取入库,由 meituan-etl.timer 每小时触发(Linux 服务器用)。
|
||||
#
|
||||
# 仅用于 mentor 的 Linux 服务器;本机 Windows 开发无 systemd,直接手动跑脚本即可:
|
||||
# .venv\Scripts\python -m scripts.pull_meituan_coupons --once --prune-hours 24
|
||||
#
|
||||
# 部署(服务器):
|
||||
# sudo cp deploy/meituan-etl.{service,timer} /etc/systemd/system/
|
||||
# sudo systemctl daemon-reload && sudo systemctl enable --now meituan-etl.timer
|
||||
#
|
||||
# 前置:DATABASE_URL 必须是 postgresql+psycopg://(ETL 的 upsert 是 PG 专用);
|
||||
# MT_CPS_APP_KEY/SECRET 已配;服务器上 MT_CPS_PROXY 留空(直连)。
|
||||
[Unit]
|
||||
Description=Meituan CPS coupon ETL (one-shot, driven by timer)
|
||||
After=network-online.target postgresql.service
|
||||
Wants=network-online.target postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
WorkingDirectory=/opt/shaguabijia-app-server
|
||||
Environment="PATH=/opt/shaguabijia-app-server/.venv/bin:/usr/bin:/bin"
|
||||
EnvironmentFile=/opt/shaguabijia-app-server/.env
|
||||
ExecStart=/opt/shaguabijia-app-server/.venv/bin/python -m scripts.pull_meituan_coupons --once --prune-hours 24
|
||||
SyslogIdentifier=meituan-etl
|
||||
# 脚本自带 30min 文件锁;给 20min 硬超时,防卡死轮次长期占锁。
|
||||
TimeoutStartSec=1200
|
||||
|
||||
# 与主服务 shaguabijia-app-server.service 同款加固。
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/opt/shaguabijia-app-server
|
||||
ProtectHome=true
|
||||
@@ -0,0 +1,14 @@
|
||||
# 每小时触发一次美团 CPS 券 ETL(Linux 服务器用)。
|
||||
# 见 meituan-etl.service 顶部注释的部署步骤。
|
||||
[Unit]
|
||||
Description=Run Meituan CPS coupon ETL hourly
|
||||
|
||||
[Timer]
|
||||
# 每小时第 5 分钟跑,避开整点其它定时任务扎堆。
|
||||
OnCalendar=*-*-* *:05:00
|
||||
# 服务器宕机/重启后,补跑错过的那一轮(而不是干等下一个整点)。
|
||||
Persistent=true
|
||||
AccuracySec=1min
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -102,6 +102,7 @@
|
||||
| A24 | `DELETE /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) |
|
||||
| A25 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin-marquee-seeds.md) |
|
||||
| A26 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./admin-marquee-seeds.md) |
|
||||
| A27 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin-ad-coin-audit.md)(看广告金币公式复算对账,只读) |
|
||||
| - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) |
|
||||
|
||||
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|---|---|---|---|
|
||||
| `ad_type` | str | 是 | 广告类型:`reward_video`(激励视频) / `draw`(Draw 信息流) 等 |
|
||||
| `ad_session_id` | str\|null | 否 | 客户端生成的广告会话 ID;需和穿山甲 `extra.ad_session_id` 一致,用于 S2S 缺 eCPM 时匹配 |
|
||||
| `ecpm` | str | 是 | 穿山甲 `getShowEcpm().getEcpm()` 原始字符串,后端按“元/千次展示”参与金币公式 |
|
||||
| `ecpm` | str | 是 | 穿山甲 `getShowEcpm().getEcpm()` 原始字符串,单位是**分/千次展示**(非元),后端 ÷100 转元参与金币公式 |
|
||||
| `adn` | str\|null | 否 | 实际投放 ADN(`getSdkName`),如 `pangle` |
|
||||
| `slot_id` | str\|null | 否 | 实际展示代码位(底层 mediation rit,非客户端配置位) |
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|---|---|---:|---|
|
||||
| `client_event_id` | string | 是 | 客户端生成的幂等事件 id,8-64 字符 |
|
||||
| `ad_session_id` | string\|null | 否 | 客户端生成的一次信息流广告会话 id,用于对账/排查 |
|
||||
| `ecpm` | string | 是 | 本条信息流广告 eCPM,按“元/千次展示”处理 |
|
||||
| `ecpm` | string | 是 | 本条信息流广告 eCPM(穿山甲 getEcpm 原值),按“分/千次展示”处理(非元) |
|
||||
| `duration_seconds` | int | 是 | 实际展示/播放秒数 |
|
||||
| `adn` | string\|null | 否 | 实际投放 ADN |
|
||||
| `slot_id` | string\|null | 否 | 实际展示代码位 |
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `reward_scene` | string | 否 | `reward_video` | 模拟发奖场景。`reward_video`=普通激励视频;`signin_boost`=签到膨胀 |
|
||||
| `ad_session_id` | string(8~64) \| null | 否 | null | 本次广告会话 id(与 [ecpm-report](./ad-ecpm-report.md) 同值)。**仅 `reward_video` 场景生效**:据此查回客户端已上报的真实 eCPM,走与正式发奖相同的公式发奖;查不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍出非零金币 |
|
||||
|
||||
## 出参
|
||||
响应 `200`:`TestGrantOut`
|
||||
@@ -30,4 +31,6 @@
|
||||
## 说明
|
||||
没公网、穿山甲 S2S 回调打不到本地时,debug 客户端看完广告后调它,直接走与 [ad-pangle-callback](./ad-pangle-callback.md) 相同的发奖逻辑(每次新 `trans_id`,幂等 + 每日上限/今日膨胀一次)。
|
||||
|
||||
`reward_scene=reward_video` 时按上面 `ad_session_id` 查回的真实 eCPM 走金币公式发奖(取不到兜底 200)——便于本地用 [admin 金币审计](./admin-ad-coin-audit.md) 核对「看广告→金币」是否按公式计算。
|
||||
|
||||
`reward_scene=signin_boost` 时复用签到膨胀业务规则:必须当天已签到、非第 14 天、当天未膨胀过,成功后写入 `signin_boost` 金币流水。它让已登录客户端能自助发奖 = 绕过反作弊,**严禁在生产开启**。
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Admin 看广告金币审计
|
||||
|
||||
> 所属:Admin 组(前缀 `/admin/api/ad-coin-audit`) | 鉴权:Admin Bearer(任意已登录 admin,只读) | [← 返回 API 索引](./README.md)
|
||||
|
||||
把「看视频赚金币」(`ad_reward_record`)和「比价信息流广告」(`ad_feed_reward_record`)两类发奖记录,用与**正式发奖完全相同**的公式 [`app/core/rewards.py` `calculate_ad_reward_coin`](../../app/core/rewards.py) 复算一遍 `expected_coin`,与实际入账的 `actual_coin` 对比,核对金币公式是否生效。**纯只读对账**,不发币、不改任何数据。
|
||||
|
||||
相关表:[ad_reward_record](../database/ad_reward_record.md)、[ad_feed_reward_record](../database/ad_feed_reward_record.md)。
|
||||
|
||||
## 金币公式(展示参照)
|
||||
|
||||
```
|
||||
eCPM元 = getEcpm分 ÷ 100
|
||||
单份金币 = round( eCPM元 ÷ 1000 × 因子1 × 因子2 × coin_per_yuan ) # coin_per_yuan=10000
|
||||
```
|
||||
- **eCPM 口径**:`getEcpm()` 原值单位是分/千次展示;先 ÷100 转元,**因子判档与收益换算都用元**。
|
||||
- **因子1(eCPM 档,阈值单位=元/千次)**:≤100→0.1,101–200→0.3,201–400→0.4,>400→0.6(即 ¥100/¥200/¥400 CPM 分界。**真实 eCPM 多 <¥100 CPM,故常落最低档 0.1,高档基本不触发——产品有意取舍**)。
|
||||
- **因子2(LT,当日第 N 份)**:第1→2.0,第2→1.5,第3→1.3,第4–10→1.1,≥11→1.0。
|
||||
- **第 N 份的计数**:看视频每条 granted 记 1 份;信息流每满 10 秒记 1 份。**两个点位各自独立计数**(看视频的份数不影响信息流的份数,反之亦然)。
|
||||
|
||||
## GET /admin/api/ad-coin-audit — 复算对比
|
||||
|
||||
- 入参(均 query,可选):
|
||||
| 参数 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `date` | string | 今天 | 北京时间 `YYYY-MM-DD`,审计某天 |
|
||||
| `user_id` | int | 全部 | 只看某用户;不传=所有用户 |
|
||||
| `scene` | string | 两类 | `reward_video` / `feed`;不传=两类都返回 |
|
||||
| `limit` | int(1~500) | 100 | 返回明细条数(按时间倒序截断;**份序号在截断前已按全天数据算好**,不影响复算正确性) |
|
||||
|
||||
- 出参 `200`:`AdCoinAuditOut`
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `date` | string | 审计日期 |
|
||||
| `formula` | object | 当前公式参数快照(见下) |
|
||||
| `total` | int | 返回明细条数 |
|
||||
| `mismatch_count` | int | 其中 `matched=false` 的条数;**=0 说明全部按公式发放** |
|
||||
| `items` | `AdCoinAuditRow[]` | 明细(见下) |
|
||||
|
||||
### AdCoinFormulaOut(`formula`)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `description` | string | 公式文字说明 |
|
||||
| `coin_per_yuan` | int | 金币:元 汇率(10000) |
|
||||
| `ecpm_unit` | string | eCPM 口径(分/千次展示,SDK getEcpm 原值) |
|
||||
| `feed_unit_seconds` | int | 信息流每多少秒折 1 份(10) |
|
||||
| `ecpm_factor_tiers` | `[因子, 下限, 上限\|null][]` | 因子1 档位表(直接读发奖常量,与发奖同源) |
|
||||
| `lt_factor_tiers` | `[因子, 下限, 上限\|null][]` | 因子2 LT 档位表 |
|
||||
|
||||
### AdCoinAuditRow(`items[]`)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `scene` | string | `reward_video` / `feed` |
|
||||
| `record_id` | int | 对应记录表主键 |
|
||||
| `user_id` | int | |
|
||||
| `created_at` | datetime | |
|
||||
| `status` | string | `granted` / `capped`(超每日上限未发) / `ecpm_missing`(缺 eCPM 未发) |
|
||||
| `ecpm` | string \| null | 本次采用的 eCPM 原始值 |
|
||||
| `ecpm_factor` | float \| null | 因子1;非 granted 为 null |
|
||||
| `units` | int | 折算份数:看视频恒 1;信息流 = 满 10 秒份数 |
|
||||
| `lt_index_start` / `lt_index_end` | int \| null | 本条占用「当日第几份」的起止(看视频起=止;信息流一条可跨多份) |
|
||||
| `lt_factor_start` / `lt_factor_end` | float \| null | 因子2 的起止值(信息流跨份时会衰减,故给区间) |
|
||||
| `expected_coin` | int | 按公式复算应发金币(非 granted 恒 0) |
|
||||
| `actual_coin` | int | 实际入账金币 |
|
||||
| `matched` | bool | 复算与实发是否一致;非 granted 校验实发是否确为 0 |
|
||||
|
||||
## 说明
|
||||
- **非 granted 行**(capped/ecpm_missing)不占用份序号、应发恒 0,`matched` 用于校验「该不发的确实没发」。
|
||||
- 用法建议:**按 `user_id`+`date` 定位某次具体核对**最直观;不带 `user_id` 是全用户当天概览。
|
||||
- 本地联调造数:debug 包看完激励视频会调 [`/api/v1/ad/test-grant`](./ad-test-grant.md),它会用客户端按 `ad_session_id` 上报的真实 eCPM 发奖(取不到兜底 200),所以本审计能看到真实 eCPM 对应的金币。
|
||||
@@ -27,7 +27,7 @@
|
||||
| `random_kind` | string | 自增长方式:`mult` ×倍率 / `add` +绝对增量 |
|
||||
| `random_step_min` | int | add 模式增量下限(基础单位) |
|
||||
| `random_step_max` | int | add 模式增量上限(基础单位) |
|
||||
| `real_offset` | int | real 模式基数偏移(基础单位):展示 = 真实值 + 偏移 |
|
||||
| `real_offset` | int | real 模式保底值(基础单位):展示 = max(真实值, 保底值)。字段名沿用 real_offset(语义已改为"保底") |
|
||||
| `allow_decrease` | bool | 是否允许展示值下降(默认 false = 只增不减) |
|
||||
| `random_current` | int \| null | 当前展示值(所有模式,基础单位) |
|
||||
| `random_last_tick_at` | string \| null | 上次 tick 时刻(ISO) |
|
||||
@@ -52,7 +52,7 @@
|
||||
| `random_kind` | string | 自增长方式:`mult` / `add` |
|
||||
| `random_step_min` | int | add 增量下限(基础单位,≥0) |
|
||||
| `random_step_max` | int | add 增量上限(基础单位,≥下限) |
|
||||
| `real_offset` | int | real 基数偏移(基础单位,≥0) |
|
||||
| `real_offset` | int | real 保底值(基础单位,≥0):展示 = max(真实值, 保底值) |
|
||||
| `allow_decrease` | bool | 允许展示值下降(默认 false=只增不减) |
|
||||
| `random_initial` | int | 切 random 的初始基数(基础单位);**留空则用当前真实值播种** |
|
||||
| `apply_now` | bool | 立即更新:不等更新钟点,保存后马上刷新一次(random 走一档,real/manual 取应有值,均经护栏) |
|
||||
|
||||
@@ -31,6 +31,16 @@
|
||||
- 入参 `OpsMarqueeSeedBulkCreate`:`count`(1~200)、`min_cents`、`max_cents`、`enabled`(默认 true)
|
||||
- 出参 `200`:`list[OpsMarqueeSeedOut]`(新建的 count 条)。`400`=数量 / 金额非法。
|
||||
|
||||
## POST /admin/api/marquee-seeds/batch-delete — 批量删除(带审计)
|
||||
对选中的多条种子一次删除(一个事务 + 一条审计)。
|
||||
- 入参 `OpsMarqueeSeedBatchDelete`:`ids`(int 数组;空数组 = no-op;不存在的 id 自动跳过)
|
||||
- 出参 `200`:`{"deleted": <实际删除条数>}`
|
||||
|
||||
## POST /admin/api/marquee-seeds/batch-enable — 批量启用 / 停用(带审计)
|
||||
对选中的多条种子一次置启用态。
|
||||
- 入参 `OpsMarqueeSeedBatchEnable`:`ids`(int 数组)、`enabled`(bool)
|
||||
- 出参 `200`:`{"updated": <实际更新条数>}`
|
||||
|
||||
## PATCH /admin/api/marquee-seeds/{seed_id} — 改(带审计)
|
||||
入参 `OpsMarqueeSeedUpdate`(均可选):`masked_user` / `min_cents` / `max_cents` / `enabled` / `sort_order`。
|
||||
- `masked_user` 约定:**不传**=不改;**传空串**=清空(改回随机合成);**传值**=固定该名。
|
||||
@@ -41,5 +51,5 @@
|
||||
出参 `200`:`{"deleted": true}`。`404`=种子不存在。
|
||||
|
||||
## 说明
|
||||
- 写操作需 operator(super 恒可),均写 `admin_audit_log`(action=`ops_marquee_seed.create` / `update` / `delete` / `bulk_generate`)。预览为只读,任意已登录 admin 可用。
|
||||
- 写操作需 operator(super 恒可),均写 `admin_audit_log`(action=`ops_marquee_seed.create` / `update` / `delete` / `bulk_generate` / `batch_delete` / `batch_enable`)。预览为只读,任意已登录 admin 可用。
|
||||
- 改动客户端**进首页 / 回前台**重拉 feed 时生效。
|
||||
|
||||
@@ -20,11 +20,11 @@
|
||||
| `page_size` | int | ❌ | 20 | 1–20 |
|
||||
|
||||
## 出参
|
||||
响应 `200`:`{ items: CouponCard[], has_next: bool, search_id: string|null }`。`CouponCard` 结构见 [API 索引](./README.md#复用数据结构)。
|
||||
响应 `200`:`{ items: CouponCard[], has_next: bool, search_id: string|null, status: "ok"|"empty"|"degraded" }`。`CouponCard` 结构见 [API 索引](./README.md#复用数据结构);`status` 语义见 [feed 接口](./meituan-feed.md#status-字段前端据此显示占位)。
|
||||
|
||||
## 错误码
|
||||
- `502` 美团接口失败(仅在**已配置 `MT_CPS_APP_KEY/APP_SECRET` 但调用失败**时;未配凭证场景见下)
|
||||
无业务级错误码:未配凭证 / 美团调用失败都返 `200` + 空 `items` + `status=degraded`(**2026-06-10 起不再抛 502**,避免前端弹报错 toast)。
|
||||
|
||||
## 说明
|
||||
- 当前 Android 客户端**未调用**此接口(搜索功能尚未实现),仅后端实现完整
|
||||
- **未配置 MT_CPS 凭证时降级**:`settings.mt_cps_configured == false` 时直接返 `{ items: [], has_next: false, search_id: null }`,**不报 502**。设计目的是让新开发机首屏不炸——后端业务层先不依赖 CPS。已配凭证但调美团失败时才走 502
|
||||
- **统一软降级(2026-06-10)**:未配 `MT_CPS_APP_KEY/SECRET`、或已配但调美团失败,都返空 + `status=degraded`(此前后者抛 502);成功但无结果 → `status=empty`。前端按 `status` 显示「服务繁忙」/「暂无」,不再弹错误
|
||||
|
||||
+26
-16
@@ -1,31 +1,41 @@
|
||||
# POST /api/v1/meituan/feed — 首页混合推荐流
|
||||
# POST /api/v1/meituan/feed — 首页推荐流(多 tab)
|
||||
|
||||
> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](./README.md)
|
||||
> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](./README.md)
|
||||
>
|
||||
> 集成实现:见 [integrations/meituan](../integrations/meituan.md)(CPS S-Ca 签名、入参换算坑)。
|
||||
> 集成实现:见 [integrations/meituan](../integrations/meituan.md)(CPS S-Ca 签名、入参换算坑);离线库见 [database/meituan_coupon](../database/meituan_coupon.md)。
|
||||
|
||||
## 入参
|
||||
|
||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `longitude` | float | ✅ | — | 经度 |
|
||||
| `latitude` | float | ✅ | — | 纬度 |
|
||||
| `page` | int | ❌ | 1 | ≥1 |
|
||||
| `page_size` | int | ❌ | 20 | 1–20 |
|
||||
| `tab` | string | ❌ | `""` | `rec` 智能推荐 / `distance` 距离最近 / 空=默认混合 feed(老客户端兼容) |
|
||||
|
||||
> 销量最高(sales)tab **不在本接口**,走 [`/top-sales`](./meituan-top-sales.md)。
|
||||
|
||||
## 出参
|
||||
响应 `200`:`{ items: CouponCard[], has_next: bool, page: int }`。`CouponCard` 结构见 [API 索引](./README.md#复用数据结构)。
|
||||
响应 `200`:`{ items: CouponCard[], has_next: bool, page: int, status: "ok"|"empty"|"degraded" }`。`CouponCard` 结构见 [API 索引](./README.md#复用数据结构)。
|
||||
|
||||
### `status` 字段(前端据此显示占位)
|
||||
| 值 | 含义 | 前端建议 |
|
||||
|---|---|---|
|
||||
| `ok` | 有数据 | 正常渲染 |
|
||||
| `empty` | 后端正常、查询成功但确实没数据 | 显示「暂无优惠券」 |
|
||||
| `degraded` | 上游(美团)或库查询失败,已降级返空 | 显示「服务繁忙,下拉重试」 |
|
||||
|
||||
> 老客户端不读 `status`(字段缺省即兼容),仍可只按 `items` 是否为空展示。
|
||||
|
||||
## 各 tab 行为
|
||||
- **`rec` 智能推荐**:走【离线库 `meituan_coupon`】筛佣金率 ≥ 3%,`DISTINCT ON(dedup_key)` 去重后按销量降序分页。**纯库查询、不打美团、不依赖 MT 凭证**。库为空(prod 刚部署 / ETL 未跑完)→ `empty`;库异常 → `degraded`。**不显示距离**(库里距离相对城市默认点,对用户无意义)。
|
||||
- 为何不实时:实测同城热销榜中位佣金 ~0.8%,筛佣金≥3% 后每页剩 0–1 条,既撞 402 又填不满,故从库出;库空时也**不回退实时**(回退同样填不满)。
|
||||
- **`distance` 距离最近**:外卖搜「外卖」+ 到店搜「美食」(均 `sortField=6`),两路并行顺序翻页,实时按你坐标算距离、由近及远。两路**都失败**且无结果 → `degraded`;否则有结果 `ok` / 无结果 `empty`。
|
||||
- **默认(空 tab)**:逐轮分页的混合 feed(2 外卖 + 1 到店交叉,写死 3 页:爆款 / 今日必推 / 精选+限时),第 4 页返空。两路榜单都失败 → `degraded`。
|
||||
|
||||
## 错误码
|
||||
无(任何失败场景都返 200 + 空 items,见"说明"中的可观测性盲区)
|
||||
无业务级错误码:任何失败场景都返 `200` + 空 `items` + `status=degraded`(不再抛 5xx,避免前端弹报错 toast)。
|
||||
|
||||
## 说明
|
||||
后端在 `coupons` 之上封装的"伪推荐"——每页并发拉一次外卖、一次到店(到餐)榜单,按 **2 外卖 + 1 到店** 交叉去重。榜单组合写死 3 页(第1页爆款、第2页今日必推、第3页外卖精选+到店限时),**第 3 页起 `has_next=false`、第 4 页返空**。
|
||||
|
||||
**两种空结果路径**:
|
||||
- **未配置 `MT_CPS_APP_KEY/APP_SECRET`**:开头 `settings.mt_cps_configured` 判定后**直接返空**,完全不调美团
|
||||
- **已配置但美团调用失败**:`_fetch_topic` 的 `except MeituanCpsError` **静默返空**(不报 502、不打日志)
|
||||
|
||||
⚠️ **可观测性盲区**:两种路径在响应上**无法区分**。若 `items` 为空想区分:
|
||||
- 看 `settings.mt_cps_configured`(`/health` 接口有暴露此字段)→ 区分"配置缺失" vs "调用失败"
|
||||
- 改调 `coupons` 接口——它在"已配凭证但调用失败"时会 502 暴露错误细节(未配凭证它也降级返空,跟 feed 一致)
|
||||
## 兜底说明(1.0 内测)
|
||||
- rec / sales 是库查询:**prod 刚部署、ETL 首灌未完成**时库为空 → 返 `empty`。最佳实践是**开内测前先手动跑一次 ETL 灌满库**(见 `scripts/pull_meituan_coupons.py` 与 `deploy/meituan-etl.{service,timer}`)。
|
||||
- 未配 `MT_CPS_APP_KEY/SECRET` 时:`distance` / 默认 直接 `degraded`;`rec` 仍走库(配置开关不挡纯库查询)。
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# POST /api/v1/meituan/top-sales — 销量最高(离线库)
|
||||
|
||||
> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](./README.md)
|
||||
>
|
||||
> 数据来自离线库 [database/meituan_coupon](../database/meituan_coupon.md);**不实时打美团**(美团搜索对销量排序支持差、且有 402 限流)。
|
||||
|
||||
## 入参
|
||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `page` | int | ❌ | 1 | ≥1 |
|
||||
| `page_size` | int | ❌ | 20 | 1–50 |
|
||||
| `platform` | int \| null | ❌ | null | 1 只外卖 / 2 只到店 / 不填=全部(全城销量) |
|
||||
|
||||
## 出参
|
||||
响应 `200`:`{ items: CouponCard[], has_next: bool, search_id: null, status: "ok"|"empty"|"degraded" }`。`CouponCard` 见 [API 索引](./README.md#复用数据结构);`status` 语义见 [feed 接口](./meituan-feed.md#status-字段前端据此显示占位)。
|
||||
|
||||
## 说明
|
||||
- 从 `meituan_coupon` 取 `sale_volume_num` 非空的券,`DISTINCT ON(dedup_key)` 跨源去重(每个「品牌|名|价」只留销量最高一条,同销量再按佣金),按销量降序分页;每页只对当前 ~20 条做 `from_raw` 解析(翻页快,不全表拉取)。
|
||||
- **不依赖 MT 凭证**(纯库查询)。库为空(prod 刚部署 / ETL 未跑完)→ `status=empty`;库查询异常 → `status=degraded`。均返 `200`、不抛 5xx。
|
||||
- **仅 PostgreSQL**(`DISTINCT ON` 为 PG 专用)。
|
||||
|
||||
## 错误码
|
||||
无业务级错误码:库空 / 异常都返 `200` + 空 `items` + 对应 `status`。
|
||||
@@ -15,13 +15,15 @@
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `items` | list | 轮播条目数组(最多 `limit` 条) |
|
||||
| `items[].masked_user` | string | 脱敏用户名,如 `用户********a52` |
|
||||
| `items[].masked_user` | string | 脱敏用户名,手机尾号(`138****5678`)/ 中文昵称(`省钱**`)混合风格 |
|
||||
| `items[].saved_amount_cents` | int | 节省(分),客户端 ÷100 显示「x.xx 元」 |
|
||||
| `items[].time` | string | 北京时间 `HH:MM:SS` |
|
||||
|
||||
## 说明
|
||||
- 真实条:`comparison_record` 中 `status='success'` 且 `0 < saved_amount_cents ≤ 300 元` 的近期记录(金额超 300 元视为异常 / bug 值剔除,防「节省 999 元」穿帮),**按 `user_id` 去重**(同一用户只取最新一条,避免单人刷屏);用户名按 `user_id` 哈希脱敏(`用户********`+3 位)。
|
||||
- 种子条:`ops_marquee_seed`(`enabled=true`),仅在真实去重后不足 `limit` 时补齐;**从启用种子中公平随机抽取**(不再固定取前 N,所有种子都有机会露出)。每条种子:用户名留空则按脱敏格式**随机合成并避开同屏撞名**,金额在 `[min_cents, max_cents]` 区间**随机取值**(固定金额则 min==max)。
|
||||
- **`time` 为合成的「最近」时间**(从当前北京时间往前递减,首条约 20 秒前,其余每条 2~6 分钟):社会证明轮播保证永远像刚发生,不受旧测试数据 / 低谷期记录影响。真实的用户/金额不变,只换展示时间。
|
||||
- 真实条:`comparison_record` 中 `status='success'` 且 `0 < saved_amount_cents ≤ 300 元` 的近期记录(金额超 300 元视为异常 / bug 值剔除,防「节省 999 元」穿帮),**按 `user_id` 去重**(同一用户只取最新一条,避免单人刷屏);用户名按 `user_id` **确定性合成**手机尾号 / 中文昵称混合脱敏名(同用户恒定)。
|
||||
- 种子条:`ops_marquee_seed`(`enabled=true`),仅在真实去重后不足 `limit` 时补齐;**从启用种子中公平随机抽取**(不再固定取前 N,所有种子都有机会露出)。每条种子:用户名留空或旧 `用户****xxx` 模板名则**随机合成混合风格名并避开同屏撞名**(自愈历史种子),金额在 `[min_cents, max_cents]` 取**长尾随机值**(小额居多、偶尔大额,固定金额则 min==max)。
|
||||
- 兜底:真实 + 种子仍凑不满 `limit`(种子被全停用 / 太少)时,用**内置合成条补满**,保证轮播既不空也不稀疏。
|
||||
- **`time` 为合成的「最近」时间**(从当前北京时间往前**随机抖动**递减,首条几十秒前,其余多数 1~5 分钟、偶尔扎堆或较长):社会证明轮播保证永远像刚发生且节奏自然不机械,不受旧测试数据 / 低谷期记录影响。真实的用户/金额不变,只换展示时间。
|
||||
- 因含随机(抽取 / 金额 / 合成名),**每次请求结果都不同**——这是轮播想要的鲜活感。
|
||||
- **性能**:首页人人都看、真实数据变化慢,真实记录原始行带 **~30s 进程内缓存**(展示层随机仍每次重算,只省 DB 往返;新记录最多晚 30s 进轮播)。查询走复合索引 `ix_comparison_status_created (status, created_at)`,避免随数据量增大全表扫。
|
||||
- 逻辑见 `app/repositories/ops_marquee.py`;运营管理种子见 [admin-marquee-seeds](./admin-marquee-seeds.md)。
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
## 说明
|
||||
三指标各自独立选模式,且**统一「定时刷新」**:客户端看到的展示值只在「更新时间(按更新间隔)」对齐的北京钟点(`anchor + k*间隔`)刷新一次,平时不变。每次刷新按模式算新值:
|
||||
- **real**:重新快照查库 + 基数偏移。`help_users`=有过 `status='success'` 比价记录的去重用户数;`total_compares`=成功比价记录数;`total_saved_cents`=成功记录 `saved_amount_cents` 求和(**只计 `0<saved≤300 元` 的条目**,剔除 bug 异常大值);最终展示 = 真实值 + 运营配的 `real_offset`。
|
||||
- **real**:重新快照查库,再与保底值取大。`help_users`=有过 `status='success'` 比价记录的去重用户数;`total_compares`=成功比价记录数;`total_saved_cents`=成功记录 `saved_amount_cents` 求和(**只计 `0<saved≤300 元` 的条目**,剔除 bug 异常大值);最终展示 = `max(真实值, 运营配的 real_offset 保底值)`。
|
||||
- **manual**:取运营当前手填的固定值(改值到下个更新钟点生效)。
|
||||
- **random**:在上次展示值上按方式增长——`mult` ×一个 [1.0,1.5] 随机倍率 / `add` +一个 [下限,上限] 随机绝对增量(跨几个钟点走几次)。
|
||||
- **只增不减**:默认门面数字不回退(real/manual 新值低于当前则保持),除非运营开「允许下降」。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 跨表视角。单表字段级细节看同目录 `<表名>.md`(索引见 [README](./README.md))。
|
||||
> 本文专门回答三件「跨表」的事:**① 每块 App 功能用到哪些表 ② 什么操作往哪张表写 ③ 表和表怎么连(join key,含没有外键约束、靠业务字段对齐的语义关联)**。
|
||||
> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **22 张业务表** + `alembic_version`(框架的迁移版本指针)。
|
||||
> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **28 张业务表** + `alembic_version`(框架的迁移版本指针)。领券联动的「今日状态」三张表(`coupon_*`)同理:领券过程在 pricebot 内存态跑、**不落库**,只有结果回到 app-server 才落这三张表。
|
||||
|
||||
---
|
||||
|
||||
@@ -17,6 +17,14 @@
|
||||
| profile「累计省了 / 省钱战绩 / 省钱明细」 | [`savings_record`](./savings_record.md) | 真实下单归因(source=compare)+ 无真实数据时 demo 兜底 |
|
||||
| 「上报更低价」提交 / 列表 | [`price_report`](./price_report.md) | 众包纠偏:用户举证某平台更便宜,人工审核发奖 |
|
||||
|
||||
### 领券(每日领券联动 · 今日状态)
|
||||
| App 位置 / 动作 | 表 | 说明 |
|
||||
|---|---|---|
|
||||
| 领券**过程**(看屏→领券) | (无) | 在 pricebot-backend 内存态跑,**过程不落库**;结果回 app-server 才落下面三张表 |
|
||||
| 切外卖 App 时是否弹领券引导窗 | [`coupon_prompt_engagement`](./coupon_state.md) | 今天 engage 过(点领/点拒)就不再弹;判断维度 device_id |
|
||||
| 首页「去领取」卡是否置灰 | [`coupon_daily_completion`](./coupon_state.md) | 今天跑完整轮(到 done)就置灰;判断维度 device_id |
|
||||
| 每张券领取结果留痕 | [`coupon_claim_record`](./coupon_state.md) | 资产/画像/排查/CPS;当前**不参与**判断 |
|
||||
|
||||
### 钱包 / 福利(看广告赚钱闭环)
|
||||
| App 位置 / 动作 | 表 | 说明 |
|
||||
|---|---|---|
|
||||
@@ -35,6 +43,7 @@
|
||||
| App 位置 / 动作 | 表 | 说明 |
|
||||
|---|---|---|
|
||||
| 登录(极光/短信)/ 改资料 / 注销 | [`user`](./user.md) | 登录主体,注册即登录 |
|
||||
| 新手引导是否再展示 | [`onboarding_completion`](./onboarding_completion.md) | 按 设备+账号 去重;登录响应回 `onboarding_completed`,走完引导时标记,跨卸载重装 |
|
||||
| 帮助与反馈 | [`feedback`](./feedback.md) | 含截图,后台人工处理 |
|
||||
|
||||
### 运营后台 admin(独立子应用 `app/admin/`,端口 8771,独立鉴权)
|
||||
@@ -54,7 +63,8 @@
|
||||
### C 端(App 用户触发)
|
||||
| 触发(用户动作 / endpoint / 回调) | 写入 | 操作 |
|
||||
|---|---|---|
|
||||
| 登录 `POST /auth/jverify-login`、`/auth/sms/login` | `user` | C(首次=注册)/ U(`last_login_at`) |
|
||||
| 登录 `POST /auth/jverify-login`、`/auth/sms/login` | `user` | C(首次=注册)/ U(`last_login_at`);并**读** `onboarding_completion` 回 `onboarding_completed` |
|
||||
| 走完新手引导 `POST /user/onboarding/complete` | `onboarding_completion`(C) | `(user_id, device_id)` 幂等,撞唯一约束即忽略 |
|
||||
| 改昵称 `PATCH /user/profile`、传头像 `POST /user/avatar` | `user` | U |
|
||||
| 注销 `DELETE /user` | `user` | U(软删:`phone→deleted_<id>`、`status=deleted`) |
|
||||
| 绑/解绑微信 `POST /wallet/bind-wechat`、`/unbind-wechat` | `user`.wechat_* | U |
|
||||
@@ -74,6 +84,13 @@
|
||||
| 首次进 profile 省钱页且无真实记录 | `savings_record`(C `source=demo`) | 懒种子,`ensure_seeded` 按 user 幂等 |
|
||||
| 上报更低价 `POST /report` | `price_report`(C) | 读 `comparison_record.best_price_cents` 校验 |
|
||||
| 提交反馈 `POST /feedback` | `feedback`(C) | |
|
||||
| 领券首帧 `POST /api/v1/coupon/step`(step=0) | `coupon_prompt_engagement`(C/U `claim_started`) | `(device_id, 北京日)` 幂等;best-effort |
|
||||
| 领券每帧结果 `POST /api/v1/coupon/step` | `coupon_claim_record`(C/U) | `(device_id, coupon_id, 北京日)` 幂等;best-effort |
|
||||
| 领券跑完 `POST /api/v1/coupon/step`(action.command=done) | `coupon_daily_completion`(C/U) | `(device_id, 北京日)` 幂等;best-effort |
|
||||
| 拒绝领券引导窗 `POST /api/v1/coupon/prompt/dismiss` | `coupon_prompt_engagement`(C/U `dismissed`) | 同上;客户端通知(透传链路看不到拒绝) |
|
||||
| 重置今日弹窗 `POST /api/v1/coupon/prompt/reset`(开发) | `coupon_prompt_engagement`(**D** 今日条) | 删后今天又能弹 |
|
||||
|
||||
> 领券三表写库**全 best-effort**:`/coupon/step` 里写失败只 `logger.warning`、不连累领券返回;**判断只看 `device_id`**,`user_id` 可空旁路(资产留痕)。
|
||||
|
||||
### admin 端(管理员触发,均额外写一条 `admin_audit_log`)
|
||||
| 后台操作 | 写入 | 操作 |
|
||||
@@ -120,6 +137,8 @@
|
||||
- **`comparison_record.store_name` ≈ `savings_record.shop_name`**:无 id 关联,按**店名字符串相等**给比价记录打「已下单」标记(瞬态,不写库)。两边店名同源 = 比价意图识别阶段的门店 query,语义=**店级**(同店比价多次会一并标已下单)。
|
||||
- **广告流会话关联**:`ad_reward_record.ad_session_id` 可与 `ad_ecpm_record.ad_session_id` 对齐;`ad_watch_log` 仍是旧版兼容统计,不逐条参与发奖。
|
||||
- **里程碑解锁进度不存库**:`comparison_milestone_claim` 只记「哪几档已领」;进度 = `comparison_record` 里 `status='success'` 的 `count`。
|
||||
- **领券三表无硬 FK,全靠软关联**:`coupon_prompt_engagement` / `coupon_daily_completion` / `coupon_claim_record` 的 `user_id` **软指** `user.id`(可空、有登录态才记、不进唯一键、不阻塞判断);`trace_id` **软指** pricebot work_logs(排查回指);唯一键都以 `device_id` + 北京自然日为主(详见 [`coupon_state.md`](./coupon_state.md))。
|
||||
- **`onboarding_completion.(user_id, device_id)`**:`user_id` 语义关联 `user.id`(无硬 FK,同 `coupon_*` 设备表),`device_id` = 客户端硬件级 `ANDROID_ID`(≠ 领券 per-install `device_id`)。登录读、走完引导写,决定是否再展示新手引导。
|
||||
|
||||
### ER 关系(文字版)
|
||||
```
|
||||
@@ -129,9 +148,12 @@ user ─1:N─ { coin_transaction, cash_transaction, withdraw_order, signin_reco
|
||||
signin_boost_record, user_task, comparison_record, comparison_milestone_claim,
|
||||
savings_record, ad_reward_record, ad_watch_log, ad_ecpm_record, ad_feed_reward_record,
|
||||
price_report, feedback }
|
||||
user ─1:N─ onboarding_completion (user_id, 无硬 FK; (user_id,device_id) 去重)
|
||||
comparison_record ─1:N─ price_report (comparison_record_id, 可空)
|
||||
admin_user ─1:N─ admin_audit_log
|
||||
app_config (独立, 无外键, key 为主键)
|
||||
coupon_prompt_engagement / coupon_daily_completion / coupon_claim_record
|
||||
(独立, 无硬 FK; 维度=device_id+北京日, user_id/trace_id 仅软关联)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+15
-2
@@ -3,18 +3,19 @@
|
||||
> 数据库:SQLite 起步(`data/app.db`),生产可切 PostgreSQL(改 `DATABASE_URL`)。
|
||||
> ORM:SQLAlchemy 2.0(`app/models/`),迁移:Alembic(`alembic/versions/`,`render_as_batch` 兼容 SQLite)。
|
||||
> 金额字段一律存**整数**:金币=个数,现金=**分**(`*_cents`)。时间列 `DateTime(timezone=True)`。
|
||||
> 最后更新:2026-06-07(补全 22 张业务表 + 新增 [OVERVIEW 总览](./OVERVIEW.md))
|
||||
> 最后更新:2026-06-11(合并:新增 3 张领券今日状态表 `coupon_*` + `onboarding_completion` 新手引导完成表;含 [OVERVIEW 总览](./OVERVIEW.md))
|
||||
|
||||
> 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。
|
||||
|
||||
---
|
||||
|
||||
## 表总览(22 张业务表 + `alembic_version` 框架表)
|
||||
## 表总览(28 张业务表 + `alembic_version` 框架表)
|
||||
|
||||
### 账号 / 反馈
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
|---|---|---|---|
|
||||
| `user` | 用户(登录主体);几乎所有表的外键宿主 | `models/user.py` | [详情](./user.md) |
|
||||
| `onboarding_completion` | 新手引导完成标记(按 设备+账号 去重,登录时据此跳过引导) | `models/onboarding.py` | [详情](./onboarding_completion.md) |
|
||||
| `feedback` | 用户帮助与反馈(含截图) | `models/feedback.py` | [详情](./feedback.md) |
|
||||
|
||||
### 钱包 / 福利(看广告赚钱闭环)
|
||||
@@ -41,6 +42,18 @@
|
||||
| `savings_record` | 省钱记录(profile 省钱战绩源;真实下单归因 + demo) | `models/savings.py` | [详情](./savings_record.md) |
|
||||
| `price_report` | 上报更低价(众包纠偏,人工审核发奖) | `models/price_report.py` | [详情](./price_report.md) |
|
||||
|
||||
### 领券(每日领券联动 · 今日状态)
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
|---|---|---|---|
|
||||
| `coupon_prompt_engagement` | 领券引导窗频控源(今日是否已 engage,按 device+日) | `models/coupon_state.py` | [详情](./coupon_state.md) |
|
||||
| `coupon_daily_completion` | 首页「去领取」置灰源(今日是否已跑完整轮) | `models/coupon_state.py` | [详情](./coupon_state.md) |
|
||||
| `coupon_claim_record` | 每张券领取结果沉淀(资产/画像/排查,不参与判断) | `models/coupon_state.py` | [详情](./coupon_state.md) |
|
||||
|
||||
### 美团 CPS 券缓存
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
|---|---|---|---|
|
||||
| `meituan_coupon` | 美团 CPS 券本地缓存(智能推荐/销量榜从库出,定时 ETL 灌入) | `models/meituan_coupon.py` | [详情](./meituan_coupon.md) |
|
||||
|
||||
### 首页门面数据
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
|---|---|---|---|
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
| `ad_session_id` | String(64) | UNIQUE, index, nullable | 客户端广告会话 ID;用于普通激励视频在 S2S 缺 `ecpm` 时匹配发奖 |
|
||||
| `adn` | String(32) | nullable | 实际投放 ADN(`getShowEcpm().getSdkName()`,如 `pangle`/`gdt`) |
|
||||
| `slot_id` | String(64) | nullable | 实际展示用代码位(底层 mediation rit,非客户端配置位) |
|
||||
| `ecpm_raw` | String(32) | NOT NULL | 客户端上报的 eCPM **原始串**;后端按“元/千次展示”参与金币公式 |
|
||||
| `ecpm_raw` | String(32) | NOT NULL | 客户端上报的 eCPM **原始串**(穿山甲 getEcpm 原值,单位**分/千次展示**);后端 ÷100 转元参与金币公式 |
|
||||
| `report_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它做按天聚合 |
|
||||
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# coupon_state — 领券今日状态三张表(弹窗频控 / 首页置灰 / 领券记录)
|
||||
|
||||
> 模型 `app/models/coupon_state.py` · 仓库 `app/repositories/coupon_state.py` · 接口 `app/api/v1/coupon.py`(prefix `/api/v1/coupon`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
领券(优惠券自动化)联动产生的三张「今日状态」表,都挂在领券透传端点 `POST /api/v1/coupon/step` 这条链路上(pricebot 跑领券,结果回 app-server 落库;**领券过程本身在 pricebot 内存态跑、不落库**)。三表各管一件事:
|
||||
|
||||
- **`coupon_prompt_engagement`** — 弹窗频控源。按 `(device, 自然日)` 记「今天是否对领券引导窗表达过**意向**」(点「一键领取」=`claim_started` / 点拒绝关闭=`dismissed` 都算)。切到外卖 App 时据此决定弹不弹:今天 engage 过就不再弹。
|
||||
- **`coupon_daily_completion`** — 首页置灰源。按 `(device, 自然日)` 记「今天是否已**跑完整轮**领券(到 done 帧)」。首页「去领取」卡据此置灰:今天跑完了就不能再领。
|
||||
- **`coupon_claim_record`** — 资产沉淀层。按 `(device, 券, 自然日)` 记每张券的领取结果(success/already_claimed/failed/skipped),**纯沉淀**(资产/画像/排查/CPS 归因),当前**不参与**「要不要领 / 弹不弹」的判断。
|
||||
|
||||
三表共同口径:
|
||||
- **判断维度是 `device_id`,不是 `user_id`**:券发到的是设备上登录的那个外卖账号,device 比 user 更贴近「哪个登录环境」,且 `device_id` 全链路现成、不依赖领券鉴权(领券 MVP 阶段 `/coupon/step` 不鉴权)。客户端 `getOrCreateDeviceId` 生成存 SP,**卸载重装会变 → 当新设备重新弹一次**(产品预期)。
|
||||
- **日期 = `Asia/Shanghai` 自然日**(`claim_date` / `engage_date` / `complete_date`,`repositories/coupon_state.today_cn()`)。每日可领的券(签到/天天红包)靠这天然每天一条。
|
||||
- **`user_id` 可空**:领券登录态有就记(资产/画像),可空、**不进唯一键、不阻塞判断**。
|
||||
- **`trace_id` 可空**:回指 pricebot work_logs,供排查(哪次任务领的)。
|
||||
- **engagement vs completion vs claim 的区别**:engagement = 用户**表达过意向**(点了领或拒,不管跑没跑完);completion = 这一轮**真跑到了 done**(整套流程走完);claim = **每张券一条**的结果留痕。
|
||||
|
||||
> 写库全部 **best-effort**:在 `/coupon/step` 里写库失败只 `logger.warning`、**绝不连累领券主流程/返回**(见 `app/api/v1/coupon.py`)。
|
||||
|
||||
---
|
||||
|
||||
## coupon_prompt_engagement — 弹窗频控(今日是否已对引导窗表达意向)
|
||||
|
||||
`(device_id, engage_date)` 唯一,一台设备一天一条;今天 engage 过(领或拒)就不再弹。
|
||||
|
||||
### 用在哪 / 增删改查
|
||||
- **C / U(幂等 upsert)**:`mark_engagement`。两条触发:
|
||||
- `POST /api/v1/coupon/step` 且 `step==0`(领券首帧=用户已发起领券)→ 记 `claim_started`;
|
||||
- `POST /api/v1/coupon/prompt/dismiss`(用户点关闭引导窗;server 在透传链路看不到「拒绝」,必须客户端通知)→ 记 `dismissed`。
|
||||
- 已有今天那条则覆盖 `engage_type`(并补 `user_id`),否则插入。
|
||||
- **D**:`POST /api/v1/coupon/prompt/reset`(`reset_today_engagement`)—— 删这台设备今天那条,开发设置「重置今日领券弹窗状态」按钮调,测频控用;删后今天又能弹。
|
||||
- **R**:`GET /api/v1/coupon/prompt/should-show?device_id=…`(`has_engaged_today`)→ `should_show = not 今天已 engage`。客户端切外卖 App 前查,纯后台判据。
|
||||
|
||||
### 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `device_id` | String(64) | NOT NULL | 判断/聚合维度;客户端 `getOrCreateDeviceId`,重装会变 |
|
||||
| `user_id` | Integer | index, 可空 | 登录态有就记(资产);不进唯一键、不阻塞判断 |
|
||||
| `engage_date` | **Date** | NOT NULL | **北京时间**自然日(`today_cn()`) |
|
||||
| `engage_type` | String(16) | NOT NULL | `claim_started`(点一键领取)/ `dismissed`(点拒绝关闭);仅记录区分,**判断只看「今天有没有这条」,type 不影响弹不弹** |
|
||||
| `created_at` | DateTime(tz) | server_default now() | |
|
||||
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | |
|
||||
|
||||
### 索引与约束
|
||||
- PK `id`;index `user_id`;UNIQUE(`device_id`, `engage_date`) = `uq_coupon_engage_device_date`(一台设备一天一条)。
|
||||
|
||||
### 注意
|
||||
- `device_id` 重装会变 → 重装当新设备,今天重新弹一次(产品预期)。
|
||||
- 判断只看「今天这台设备有没有这条」,不看 `engage_type`(领或拒都算 engage 过、都不再弹)。
|
||||
|
||||
---
|
||||
|
||||
## coupon_daily_completion — 首页置灰(今日是否已跑完整轮领券)
|
||||
|
||||
`(device_id, complete_date)` 唯一,一台设备一天一条;今天跑完整轮(到 done 帧)就把首页「去领取」卡置灰。
|
||||
|
||||
### 用在哪 / 增删改查
|
||||
- **C / U(幂等 upsert)**:`mark_completed_today`,由 `POST /api/v1/coupon/step` 在 pricebot 返回 `action.command == "done"` 那帧调。pricebot 把中途单券 done 改写成 `wait+continue=true`,只有整套全跑完那帧才保留 `command=="done"`,故 **done 已等价「整轮完成」**。已有今天那条则补 `user_id`/`trace_id`,否则插入。
|
||||
- **U / D**:无业务删除。
|
||||
- **R**:`GET /api/v1/coupon/completed-today?device_id=…`(`has_completed_today`)→ `completed`。客户端据此把首页「去领取」卡置灰、不可点。
|
||||
|
||||
### 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `device_id` | String(64) | NOT NULL | 判断维度,与 engagement/claim 一致;客户端两端都用 ANDROID_ID |
|
||||
| `user_id` | Integer | index, 可空 | 登录态有就记(资产) |
|
||||
| `complete_date` | **Date** | NOT NULL | **北京时间**自然日(`today_cn()`) |
|
||||
| `trace_id` | String(64) | index, 可空 | 哪次任务跑到 done,回指 pricebot work_logs / 排查 |
|
||||
| `created_at` | DateTime(tz) | server_default now() | |
|
||||
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | |
|
||||
|
||||
### 索引与约束
|
||||
- PK `id`;index `user_id`、`trace_id`;UNIQUE(`device_id`, `complete_date`) = `uq_coupon_completion_device_date`(一台设备一天一条)。
|
||||
|
||||
### 注意
|
||||
- 口径(用户决策 2026-06-10 A 方案):**到 done 即算完成,不管单券成败**——失败/跳过常是无障碍/环境问题,重复点也补不回来。
|
||||
- 与 engagement 区别:engagement 是「表达过意向」(点了就记,不管跑没跑完);completion 是「真跑到了 done」。
|
||||
|
||||
---
|
||||
|
||||
## coupon_claim_record — 领券记录(每张券一天一条,资产沉淀层)
|
||||
|
||||
`(device_id, coupon_id, claim_date)` 唯一,同设备同券同一天只一条;纯沉淀,**当前不参与判断**,留作以后按券去重 / CPS 归因 / 用户画像的数据源。
|
||||
|
||||
### 用在哪 / 增删改查
|
||||
- **C / U(幂等 upsert)**:`record_claims`,由 `POST /api/v1/coupon/step` 写入。一帧的券结果来自 pricebot 的 `last_coupon_result`(最后一张)+ `action.params.coupon_results`(全量)——**会重复带同一张券**,端点 `_extract_coupon_results` 先**按 `coupon_id` 去重**(全量覆盖单张),仓库再靠唯一键幂等:已有则更新 `status`/`reason`/`claimed_count`/`extra`(以最后一次为准),否则插入。
|
||||
- **U / D**:无业务删除。
|
||||
- **R**:**当前无读取端点**(纯写入沉淀,未来做去重/归因/画像时再用)。
|
||||
|
||||
### 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `device_id` | String(64) | NOT NULL | 聚合维度;客户端 `getOrCreateDeviceId`,重装会变 |
|
||||
| `user_id` | Integer | index, 可空 | 登录态有就记(资产/画像);不进唯一键 |
|
||||
| `coupon_id` | String(64) | NOT NULL | 券标识(取自 pricebot 结果) |
|
||||
| `claim_date` | **Date** | NOT NULL | **北京时间**自然日(`today_cn()`);每日可领的券靠它天然每天一条 |
|
||||
| `status` | String(24) | NOT NULL | `success` / `already_claimed` / `failed` / `skipped`(原样取 pricebot coupon 结果) |
|
||||
| `vendor` | String(48) | 可空 | 券提供方 |
|
||||
| `coupon_name` | String(128) | 可空 | 取 pricebot `name` |
|
||||
| `claimed_count` | Integer | 可空 | 这张领到几张(pricebot `display_count`,给不出时 None;兼容 `claimed_count`) |
|
||||
| `trace_id` | String(64) | index, 可空 | 哪次任务领的,回指 pricebot work_logs / 排查 |
|
||||
| `reason` | String(255) | 可空 | failed / skipped 原因 |
|
||||
| `extra` | JSON(PG JSONB) | 可空 | 杂项兜底:券的结构化信息(面额/入口/关键节点摘要等),免得加字段就迁移;当前直接存这帧 pricebot 单券结果 dict |
|
||||
| `created_at` | DateTime(tz) | server_default now() | |
|
||||
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | |
|
||||
|
||||
### 索引与约束
|
||||
- PK `id`;index `user_id`、`trace_id`;UNIQUE(`device_id`, `coupon_id`, `claim_date`) = `uq_coupon_claim_device_coupon_date`;Index(`device_id`, `claim_date`) = `ix_coupon_claim_device_date`(按 device+日 取一天所有券)。
|
||||
|
||||
### 注意
|
||||
- **`extra` 别塞原始无障碍树**(几十 KB → 行膨胀);原始大树看 `trace_id` 指过去的 work_logs。
|
||||
- 同批去重很关键:`autoflush=False` 下同 `coupon_id` 两次 `add` 会撞唯一约束、`IntegrityError` 回滚整批(done/单券记录全丢),故端点 `_extract_coupon_results` + 仓库 `seen` 集合双重防御。
|
||||
- `extra` 是 `JSON().with_variant(JSONB(), "postgresql")`:PG 用 JSONB(可建 GIN 索引),SQLite 退化通用 JSON(同 `price_observation` / `comparison_record`)。
|
||||
|
||||
---
|
||||
|
||||
## 三表共性小结
|
||||
- 数据流向:客户端 → `POST /api/v1/coupon/step`(透传给 pricebot)→ 结果回写这三张表(best-effort,写库失败不影响领券)。
|
||||
- 唯一键都含 `device_id` + 某个北京自然日列;`user_id` 永远是可空旁路(资产留痕,不进唯一键、不阻塞判断)。
|
||||
- 无硬外键:`user_id` 软指 `user.id`、`trace_id` 软指 pricebot work_logs(详见 [OVERVIEW → 表间关系 & Join Key](./OVERVIEW.md))。
|
||||
@@ -0,0 +1,58 @@
|
||||
# meituan_coupon — 美团 CPS 券本地缓存
|
||||
|
||||
> 模型 `app/models/meituan_coupon.py` · ETL `scripts/pull_meituan_coupons.py` · 接口 [meituan-feed](../api/meituan-feed.md)(rec/distance)/ [meituan-top-sales](../api/meituan-top-sales.md)(sales)· [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
把美团联盟 CPS 的券**定时抓进本地库**,供「智能推荐(佣金≥3%)」「销量最高」从库里捞、本地去重排序,不每次实时打美团(美团对销量排序支持差、有 402 限流和召回上限)。北京单城试点。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C/U(ETL upsert)**:`scripts/pull_meituan_coupons.py` 每小时全量抓 3 路(`search_waimai` 搜「外卖」/ `search_meishi` 搜「美食」/ `store_supply` 到店多业务线供给),按 `(source, product_view_sign)` upsert(PG `ON CONFLICT DO UPDATE`),`last_seen` 每轮刷新。
|
||||
- **R(读)**:
|
||||
- rec(智能推荐):`WHERE commission_percent>=3.0` → `DISTINCT ON(dedup_key)` 取佣金最高 → 按 `sale_volume_num` 降序分页。
|
||||
- sales(销量最高):`WHERE sale_volume_num IS NOT NULL` → `DISTINCT ON(dedup_key)` 取销量最高 → 按销量降序分页。
|
||||
- **D(清理)**:见下「过期清理策略」。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `source` | String(16) | index, NOT NULL | 召回来源:`search_waimai` / `search_meishi` / `store_supply` |
|
||||
| `platform` | Integer | NOT NULL | 1 外卖/到家, 2 到店 |
|
||||
| `biz_line` | Integer | nullable | 到店子类:1到餐 2到综 3酒店 4门票 |
|
||||
| `city_id` | String(32) | index, NOT NULL | 城市 id(当前恒为北京 `WKV2HMXUEK634WP64CUCUQGM64`) |
|
||||
| `product_view_sign` | String(128) | NOT NULL | 换链主键;**按召回渠道生成、跨渠道会变**,不能当商品全局 id |
|
||||
| `sku_view_id` | String(128) | nullable | |
|
||||
| `name` | String(256) | nullable | 商品名(解析截断 `[:256]`) |
|
||||
| `brand_name` | String(128) | index, nullable | 品牌名(`[:128]`) |
|
||||
| `sell_price_cents` | Integer | nullable | 现价(分) |
|
||||
| `original_price_cents` | Integer | nullable | 原价(分) |
|
||||
| `head_url` | String(512) | nullable | 头图(去 `@` 后缀,`[:512]`) |
|
||||
| `sale_volume` | String(32) | nullable | 原始销量档:如「热销1w+」 |
|
||||
| `sale_volume_num` | Integer | index, nullable | 销量排序数值(取下界):`1w+` → 10000 |
|
||||
| `commission_percent` | Float | index, nullable | 佣金比例,**数值即百分数**(1.4 = 1.4%);美团原值 140 ÷ 100 |
|
||||
| `commission_amount_cents` | Integer | nullable | 预估佣金(分) |
|
||||
| `poi_name` | String(128) | nullable | 最近门店名(`[:128]`) |
|
||||
| `available_poi_num` | Integer | nullable | 可用门店数 |
|
||||
| `delivery_distance_m` | Float | nullable | 距离(米);**相对抓取时的城市默认点**,对用户位置无意义,rec 接口置空不返 |
|
||||
| `dedup_key` | String(64) | index, NOT NULL | 跨源去重键 = `md5(brand_name\|name\|sell_price_cents)` |
|
||||
| `raw` | JSON / JSONB | NOT NULL, default `{}` | 整条原始返回(避免漏字段重抓);PG 上为 JSONB |
|
||||
| `first_seen` | DateTime(tz) | server_default now() | 首次抓到 |
|
||||
| `last_seen` | DateTime(tz) | index, server_default now() | 最近一轮抓到(**过期清理依据**) |
|
||||
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | |
|
||||
|
||||
## 索引与约束
|
||||
- 唯一约束 `uq_meituan_coupon_source_sign`(`source`, `product_view_sign`)—— ETL upsert 的冲突目标。
|
||||
- 单列 index:`source`、`city_id`、`brand_name`、`sale_volume_num`、`commission_percent`、`dedup_key`、`last_seen`。
|
||||
- **字段核对(2026-06-10)**:模型 ↔ 迁移(`meituan_coupon_table`)↔ ETL 解析三处一致;解析截断宽度均 ≤ 列宽(name256 / brand128 / poi128 / head512 / sign128,`dedup_key` = md5 32 字符 ≤ 64)。
|
||||
- **索引评估**:`rec` / `sales` 查询的理想索引是复合 `(dedup_key, sale_volume_num DESC)` 与 `(dedup_key, commission_percent DESC)`(匹配 `DISTINCT ON`);但当前**单城几千行,seq scan 即亚毫秒**,现有单列索引已够用,复合索引留作多城 / 放量时再加,避免过早优化。
|
||||
|
||||
## 过期清理策略
|
||||
- ETL 每轮(每小时)末尾按 **`last_seen` 的 TTL** 清理:`DELETE WHERE last_seen < now() - prune_hours`(默认 24h,`--prune-hours` 可调,0=不清)。
|
||||
- 仍在架的券每小时被刷新 `last_seen`,**永不被清**;只有下架 / `sign` 轮换的残留超 24h 宽限才删。宽限期用于吸收几次抓取失败。
|
||||
- 券自身到期**无需单独处理**:到期后美团不再返回 → 自然 `last_seen` 老化被清。
|
||||
- **护栏(2026-06-10)**:prune 仅在**本轮确有入库(`total>0`)**时执行。美团整体故障时本轮可能 0 入库(脚本不抛异常、只是抓回空),若仍照常 prune,连续故障会按 `last_seen` **把全表删空**;加 `total>0` 护栏后,0 入库轮跳过清理并打日志。
|
||||
- 表只增量 upsert + 少量 delete,PG autovacuum 足以回收死元组,当前规模无需手动 VACUUM。
|
||||
|
||||
## 注意
|
||||
- **仅 PostgreSQL**:ETL upsert(`postgresql.insert(...).on_conflict_do_update`)与读取的 `DISTINCT ON` 都是 PG 专用语法;库若是 sqlite,ETL 灌不进、rec/sales 接口报错。prod `DATABASE_URL` 必须 `postgresql+psycopg://`。
|
||||
- **`dedup_key` 不含城市**:`md5(brand|name|price)`,单城没问题;将来多城会把异城同品合并,需把 `city_id` 纳入 key。
|
||||
- `product_view_sign` 跨渠道会变,**不能当商品全局唯一 id**:存储按 `(source, sign)`、查询按 `dedup_key`。
|
||||
@@ -0,0 +1,44 @@
|
||||
# onboarding_completion — 新手引导完成标记(按 设备 + 账号 去重)
|
||||
|
||||
> 模型 `app/models/onboarding.py` · 仓库 `app/repositories/onboarding.py` · 迁移 `alembic/versions/onboarding_completion_table.py`(revision `onboarding_completion`) · 读 `POST /api/v1/auth/jverify-login`·`/sms/login` · 写 `POST /api/v1/user/onboarding/complete` · 测试 `tests/test_onboarding.py` · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
记录「**某账号在某台设备上已走完新手引导**」,一条 `(user_id, device_id)`。用途单一:决定**登录后要不要再展示新手引导教程**——本表有这条 → 跳过引导直接进首页;没有 → 走引导。
|
||||
|
||||
**为什么要落后端**:此前「引导只跑一次」只存客户端本地 `OnboardingPrefs`(SharedPreferences),**卸载重装即丢** → 重装会重弹引导。产品需求是「同设备 + 同账号只触发一次,且跨卸载重装」,本地存不住,故把"真相"放到服务端,按 **账号 + 硬件设备号** 去重。
|
||||
|
||||
**去重维度 = 设备 + 账号**(产品决议):
|
||||
|
||||
| 场景 | 命中本表? | 是否再展示引导 |
|
||||
|---|---|---|
|
||||
| 新账号(从未走过) | 否 | ✅ 展示 |
|
||||
| 同账号 + **同设备**(卸载重装 / 退登重登) | 是 | ❌ 跳过 |
|
||||
| 同账号 + **换新设备** | 否 | ✅ 展示(新机需重新授权悬浮窗/无障碍等) |
|
||||
|
||||
> ⚠️ `device_id` 用客户端**硬件级稳定标识** `Settings.Secure.ANDROID_ID`(`util/DeviceId.hardwareId()`,同签名 app 卸载重装不变、仅恢复出厂重置),**不是**领券/比价用的 per-install `DeviceId.get()`(存 SP、重装会变)。两套 device_id 不互通,别混用。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **R(读)**:登录 `POST /auth/jverify-login`、`/auth/sms/login` —— 客户端在登录请求体里带 `device_id`,`onboarding_repo.is_completed(user_id, device_id)` 查本表有无行,结果塞进登录响应 `TokenWithUser.onboarding_completed`(true=已完成)。客户端 `AuthRepository.persist` 据此回写本地 `OnboardingPrefs`(只回写 true),既有 gating(`OnboardingHost` / `AppNavHost`)逻辑不变。
|
||||
- **C(插入)**:走完引导最后一步(后台耗电屏)时,客户端 `POST /api/v1/user/onboarding/complete`(带 Bearer + `device_id`)→ `onboarding_repo.mark_completed` 插一行。**幂等**:同 `(user_id, device_id)` 已存在则撞唯一约束,`IntegrityError` 被 rollback 吞掉、视为成功;`device_id` 为空一律忽略不写。best-effort:客户端上报失败不阻断进首页(本地已先标记)。
|
||||
- **U / D**:无。一台设备 + 一账号一条,完成即永久;账号注销(软删 `user`)**不清本表**(留着无害——user_id 指向的是已 `deleted` 的行;同手机号重新注册是新 user.id,自然重新走引导)。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `user_id` | Integer | index, NOT NULL | 归属账号 → `user.id`(语义外键,未建 DB 级 FK,同 `coupon_*` 设备表) |
|
||||
| `device_id` | String(64) | NOT NULL | 硬件级设备标识 = 客户端 `Settings.Secure.ANDROID_ID`;卸载重装不变 |
|
||||
| `completed_at` | DateTime(tz) | server_default now() | 标记完成的时间 |
|
||||
|
||||
## 关系 / Join Key
|
||||
- `user_id` → `user.id`(多对一:一账号在 N 台设备各一条)。**无硬 FK 约束**(同 `coupon_claim_record` / `coupon_prompt_engagement`),靠业务字段对齐。
|
||||
- 判断维度 = `(user_id, device_id)` 组合等值查。
|
||||
- `device_id` 与客户端 `DeviceId.hardwareId()`(ANDROID_ID)同源;**不**与领券侧 per-install `device_id` 互通。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`;index `user_id`(`ix_onboarding_completion_user_id`);UNIQUE(`user_id`, `device_id`) = `uq_onboarding_user_device`(防同账号同设备重复写 + 兜并发 upsert)。
|
||||
|
||||
## 注意
|
||||
- **去重维度是「设备 + 账号」**,不是纯账号:同一个人在新手机上登录会重新看引导(产品预期:新机要重新授权权限)。若日后想改「纯账号、换机也不弹」,把 `is_completed`/`mark_completed` 去掉 `device_id` 维度即可。
|
||||
- `device_id` 取不到 / 为已知脏值(`9774d56d682e549c`)时客户端退回 per-install id,这类极少数设备重装会重弹一次(尽力而为)。
|
||||
- 本地 `OnboardingPrefs` 现仅作**同安装快速跳过缓存**;真相以本表为准,每次登录响应同步。
|
||||
- 客户端实现:`ui/onboarding/OnboardingViewModel.kt`(上报)、`data/auth/AuthRepository.kt`(读 + 同步本地)、`util/DeviceId.kt`(ANDROID_ID)。
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
> 模型 `app/models/ops_marquee_seed.py` | 关联接口 [platform-savings-feed](../api/platform-savings-feed.md) / [admin-marquee-seeds](../api/admin-marquee-seeds.md) | [← 表索引](./README.md)
|
||||
|
||||
首页「用户****xxx 比价后节省 xx 元」轮播的兜底假数据。轮播真实数据源是全平台比价记录,真实不足 N 条时用本表启用的种子补齐「混播」,保证轮播不空。种子是「生成规则」而非死记录:用户名可空(→随机合成)、金额是区间(→随机取值)。运营后台增删改。逻辑见 `app/repositories/ops_marquee.py`。
|
||||
首页「XXX 比价后节省 xx 元」轮播的兜底假数据。轮播真实数据源是全平台比价记录,真实不足 N 条时用本表启用的种子补齐「混播」,保证轮播不空。种子是「生成规则」而非死记录:用户名可空(→随机合成手机尾号/中文昵称混合名)、金额是区间(→随机取值)。运营后台增删改。逻辑见 `app/repositories/ops_marquee.py`。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `masked_user` | String(64) | NULL 可空 | 脱敏用户名,如 `用户********a52`(整串存)。**留空 → feed 展示时按脱敏格式随机合成**(`用户********`+3 位 hex,避开同屏撞名),风格与真实条一致、永不穿帮 |
|
||||
| `masked_user` | String(64) | NULL 可空 | 脱敏用户名,手机尾号(`138****5678`)/ 中文昵称(`省钱**`)风格(整串存)。**留空或旧 `用户****xxx` 模板名 → feed 展示时随机合成混合风格名**(避开同屏撞名、自愈历史种子),风格与真实条一致、永不穿帮 |
|
||||
| `min_cents` | Integer | NOT NULL | 节省金额区间下限(分) |
|
||||
| `max_cents` | Integer | NOT NULL | 节省金额区间上限(分);feed 每次在 `[min,max]` 随机取值,**固定金额则 min==max** |
|
||||
| `enabled` | Boolean | NOT NULL, default true | 停用的不参与混播 |
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
| `random_kind` | String(8) | NOT NULL, default `mult` | 自增长方式:`mult` ×倍率 / `add` +绝对增量 |
|
||||
| `random_step_min` | Integer | NOT NULL, default 0 | add 模式增量下限(基础单位) |
|
||||
| `random_step_max` | Integer | NOT NULL, default 0 | add 模式增量上限(基础单位) |
|
||||
| `real_offset` | Integer | NOT NULL, default 0 | real 模式基数偏移(基础单位):展示 = 真实值 + 偏移 |
|
||||
| `real_offset` | Integer | NOT NULL, default 0 | real 模式保底值(基础单位):展示 = max(真实值, 保底值)。列名沿用 real_offset(语义已从"偏移"改为"保底") |
|
||||
| `allow_decrease` | Boolean | NOT NULL, default false | 是否允许展示值下降(默认 false = 只增不减) |
|
||||
| `updated_by_admin_id` | Integer | nullable | 最后修改的管理员 |
|
||||
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 更新时间 |
|
||||
@@ -39,7 +39,7 @@
|
||||
`random_current` 现是**所有模式**的「当前展示值」(不止 random),用户侧 `/stats` 直接返回它。
|
||||
- 触发边界 = 北京时间 `anchor + k*interval`(interval=`random_tick_seconds`,anchor=`random_anchor_minutes*60`,sub-day 间隔取 `anchor % interval` 作相位)。例:每天 09:00 / 每小时 :30 / 每 5 分刻度。
|
||||
- 读取(`get_display_values`/`get_config`)时跑 `_refresh`:统计 `random_last_tick_at`→`now` 跨过几个边界 N,N≥1 才刷新:
|
||||
- **real** → `random_current` = 重新查库的真实值 + `real_offset`(跨多少边界都只取最新),**经只增不减护栏**。
|
||||
- **real** → `random_current` = `max(重新查库的真实值, real_offset 保底值)`(跨多少边界都只取最新),**经只增不减护栏**。
|
||||
- **manual** → `random_current` = 当前 `manual_value`,**经只增不减护栏**。
|
||||
- **random** → 按 `random_kind` 走 N 个周期:`mult` 连乘随机倍率(`randint(min,max)/1000`,恒 ≥1.0)/ `add` 连加随机增量(`randint(step_min,step_max)`,≥0)。天然只增。
|
||||
- **只增不减护栏**(`allow_decrease=false`,默认):real/manual 刷新时若新目标值 < 当前 `random_current`,保持当前值不回退(门面忌缩水)。开 `allow_decrease` 才允许下降。
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
@echo off
|
||||
REM Local dev startup script (Windows) - mirror of run.sh
|
||||
REM
|
||||
REM Usage:
|
||||
REM cd shaguabijia-app-server
|
||||
REM run.bat
|
||||
REM
|
||||
REM Listens on 0.0.0.0:8770 (works for both real device via adb reverse
|
||||
REM and LAN). Auto-reload on code change.
|
||||
REM
|
||||
REM Prerequisite (first time):
|
||||
REM conda activate pricebot ^&^& pip install -e .
|
||||
REM copy .env.example .env ^&^& fill JWT_SECRET_KEY
|
||||
REM
|
||||
REM This file is the Windows-native peer of run.sh. Keeping run.sh untouched
|
||||
REM avoids CRLF/encoding fights every time someone bash-runs the .sh on Win.
|
||||
|
||||
cd /d "%~dp0"
|
||||
|
||||
if not exist .env (
|
||||
echo [X] Missing .env. Run: copy .env.example .env and fill JWT_SECRET_KEY ^(plus MT_CPS_* if you test Meituan^)
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not exist data mkdir data
|
||||
|
||||
REM Build/upgrade SQLite schema (idempotent; no-op if already at head)
|
||||
call alembic upgrade head
|
||||
if errorlevel 1 (
|
||||
echo [X] alembic upgrade head failed
|
||||
exit /b %errorlevel%
|
||||
)
|
||||
|
||||
REM Long-running foreground process. Ctrl+C to stop.
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8770 --reload
|
||||
@@ -22,6 +22,7 @@ import hashlib
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
@@ -44,7 +45,10 @@ PAGE_SIZE = 20
|
||||
MAX_PAGES = 80 # 单路安全上限(搜索 ~52 页、供给 ~70 页)
|
||||
PAGE_SLEEP = 0.35 # 页间配速,缓解 402
|
||||
RETRY = 7
|
||||
LOCK_FILE = "data/.meituan_etl.lock"
|
||||
# 运行锁默认放系统临时目录(任何账号可写),不依赖代码目录 data/ 的写权限——
|
||||
# 无 sudo 部署时 data/ 常属 root,cps 写不了会导致每轮 PermissionError、cron 抓不进数据。
|
||||
# 需要指定位置时用环境变量 MEITUAN_ETL_LOCK 覆盖。
|
||||
LOCK_FILE = os.environ.get("MEITUAN_ETL_LOCK") or os.path.join(tempfile.gettempdir(), "meituan_etl.lock")
|
||||
LOCK_STALE_SEC = 30 * 60 # 锁超过 30min 视为陈旧(进程异常退出残留),自动接管
|
||||
|
||||
SOURCES = [
|
||||
@@ -288,8 +292,10 @@ def run_once(prune_hours: int = 24) -> None:
|
||||
total += up
|
||||
print(f" {src['label']:18} 抓{len(items):5} 解析{len(parsed):5} "
|
||||
f"入库{up:5} (本源去重{dup:3}) {time.time() - ts:4.0f}s")
|
||||
# 清理长期未再出现的陈旧券(美团 sign 轮换 / 券下架后的残留),默认 24h 宽限
|
||||
if prune_hours and prune_hours > 0:
|
||||
# 清理长期未再出现的陈旧券(美团 sign 轮换 / 券下架后的残留),默认 24h 宽限。
|
||||
# 护栏:仅在本轮确有入库(total>0)时才清理。美团整体故障时本轮可能 0 入库
|
||||
# (脚本不抛异常、只是抓回空),若仍照常 prune,连续故障会按 last_seen 把全表删空。
|
||||
if prune_hours and prune_hours > 0 and total > 0:
|
||||
cutoff = now - timedelta(hours=prune_hours)
|
||||
pruned = db.execute(
|
||||
delete(MeituanCoupon).where(MeituanCoupon.last_seen < cutoff)
|
||||
@@ -297,6 +303,8 @@ def run_once(prune_hours: int = 24) -> None:
|
||||
db.commit()
|
||||
if pruned:
|
||||
print(f" 清理陈旧券(>{prune_hours}h 未再出现): {pruned} 条")
|
||||
elif prune_hours and prune_hours > 0 and total == 0:
|
||||
print(" 本轮 0 入库(疑似上游故障),跳过清理以防误删全表")
|
||||
cnt = db.execute(select(func.count()).select_from(MeituanCoupon)).scalar()
|
||||
print(f"[{datetime.now():%H:%M:%S}] 本轮完成: 入库 {total} 条, 表总计 {cnt} 行, "
|
||||
f"用时 {time.time() - t0:.0f}s")
|
||||
|
||||
@@ -160,6 +160,48 @@ def test_set_user_status_and_audit(admin_client: TestClient, operator_token: str
|
||||
).status_code == 200
|
||||
|
||||
|
||||
# ===== 一键开启新手引导 =====
|
||||
|
||||
def test_set_force_onboarding_and_audit(admin_client: TestClient, operator_token: str) -> None:
|
||||
uid = _seed_user("13900000009")
|
||||
r = admin_client.post(
|
||||
f"/admin/api/users/{uid}/force-onboarding", json={"enabled": True},
|
||||
headers=_auth(operator_token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
db = SessionLocal()
|
||||
try:
|
||||
assert db.get(User, uid).force_onboarding is True
|
||||
logs = db.execute(
|
||||
select(AdminAuditLog).where(
|
||||
AdminAuditLog.action == "user.force_onboarding.set",
|
||||
AdminAuditLog.target_id == str(uid),
|
||||
)
|
||||
).scalars().all()
|
||||
assert logs[0].detail == {"before": False, "after": True}
|
||||
finally:
|
||||
db.close()
|
||||
# 取消(回到 false)
|
||||
assert admin_client.post(
|
||||
f"/admin/api/users/{uid}/force-onboarding", json={"enabled": False},
|
||||
headers=_auth(operator_token),
|
||||
).status_code == 200
|
||||
db = SessionLocal()
|
||||
try:
|
||||
assert db.get(User, uid).force_onboarding is False
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_finance_cannot_force_onboarding(admin_client: TestClient, finance_token: str) -> None:
|
||||
uid = _seed_user("13900000010")
|
||||
r = admin_client.post(
|
||||
f"/admin/api/users/{uid}/force-onboarding", json={"enabled": True},
|
||||
headers=_auth(finance_token),
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
# ===== 角色守卫 =====
|
||||
|
||||
def test_operator_cannot_grant_coins(admin_client: TestClient, operator_token: str) -> None:
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""新手引导"按 设备+账号 只触发一次"的后端闭环测试。
|
||||
|
||||
覆盖:
|
||||
- 全新 (账号, 设备) 登录 → onboarding_completed=False(照常走引导)
|
||||
- 标记完成后,同 (账号, 设备) 再登录 → True(跳过引导,跨"重装"= 同 device_id 重新登录)
|
||||
- 同账号换设备(device_id 不同)→ False(新设备重新引导)
|
||||
- 标记幂等;缺 device_id 时按未完成处理
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _login(client, phone: str, device_id: str | None):
|
||||
body = {"phone": phone, "code": "123456"}
|
||||
if device_id is not None:
|
||||
body["device_id"] = device_id
|
||||
r = client.post("/api/v1/auth/sms/login", json=body)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def _mark_complete(client, access: str, device_id: str):
|
||||
return client.post(
|
||||
"/api/v1/user/onboarding/complete",
|
||||
json={"device_id": device_id},
|
||||
headers={"Authorization": f"Bearer {access}"},
|
||||
)
|
||||
|
||||
|
||||
def test_onboarding_once_per_device_and_account(client) -> None:
|
||||
phone = "13800138001"
|
||||
dev_a = "android-id-aaaa"
|
||||
dev_b = "android-id-bbbb"
|
||||
|
||||
# ① 全新账号+设备:未完成
|
||||
data = _login(client, phone, dev_a)
|
||||
assert data["onboarding_completed"] is False
|
||||
access = data["access_token"]
|
||||
|
||||
# ② 走完引导 → 标记完成(幂等:再标一次也 200)
|
||||
assert _mark_complete(client, access, dev_a).status_code == 200
|
||||
assert _mark_complete(client, access, dev_a).json()["ok"] is True
|
||||
|
||||
# ③ 同账号 + 同设备(模拟卸载重装后重新登录)→ 已完成,跳过引导
|
||||
assert _login(client, phone, dev_a)["onboarding_completed"] is True
|
||||
|
||||
# ④ 同账号 + 新设备 → 仍未完成,新设备重新走引导
|
||||
assert _login(client, phone, dev_b)["onboarding_completed"] is False
|
||||
|
||||
|
||||
def test_onboarding_mark_on_one_device_does_not_leak_to_other(client) -> None:
|
||||
"""B 设备标记完成,不影响 A 设备(逐设备独立)。"""
|
||||
phone = "13800138002"
|
||||
data_a = _login(client, phone, "devA")
|
||||
assert data_a["onboarding_completed"] is False
|
||||
|
||||
data_b = _login(client, phone, "devB")
|
||||
assert _mark_complete(client, data_b["access_token"], "devB").status_code == 200
|
||||
|
||||
assert _login(client, phone, "devB")["onboarding_completed"] is True
|
||||
assert _login(client, phone, "devA")["onboarding_completed"] is False
|
||||
|
||||
|
||||
def test_onboarding_missing_device_id_treated_as_incomplete(client) -> None:
|
||||
"""老客户端不传 device_id:按未完成处理,不报错、不误跳过。"""
|
||||
phone = "13800138003"
|
||||
data = _login(client, phone, device_id=None)
|
||||
assert data["onboarding_completed"] is False
|
||||
|
||||
|
||||
def test_complete_clears_force_onboarding(client) -> None:
|
||||
"""运营「一键开启新手引导」(force_onboarding=True)后,用户走完引导即自动清除该标记,
|
||||
/me 随之回到 false——否则下次启动会被无限拉回引导。"""
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.user import User
|
||||
|
||||
phone = "13800138004"
|
||||
dev = "android-id-force"
|
||||
data = _login(client, phone, dev)
|
||||
access = data["access_token"]
|
||||
uid = data["user"]["id"]
|
||||
|
||||
# 模拟运营在后台一键开启(admin 接口本身另在 test_admin_write 覆盖)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.get(User, uid).force_onboarding = True
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def _me() -> dict:
|
||||
return client.get(
|
||||
"/api/v1/auth/me", headers={"Authorization": f"Bearer {access}"}
|
||||
).json()
|
||||
|
||||
assert _me()["force_onboarding"] is True
|
||||
assert _mark_complete(client, access, dev).status_code == 200
|
||||
assert _me()["force_onboarding"] is False
|
||||
Reference in New Issue
Block a user