Compare commits

...

9 Commits

Author SHA1 Message Date
marco f97048ff56 fix(alembic): 合并多 head (store_mapping_jd_dl_invalid + ad_revenue) 修复 0.1.2 部署
PR #56 的 jd_deeplink_invalid 迁移与 ad_revenue 报表迁移从同一 branchpoint
分叉成双 head, 部署时 alembic upgrade head 报 "Multiple head revisions"。
加一个 no-op merge 节点统一 DAG, 两支迁移列不冲突。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 01:39:05 +08:00
marco f15ca74a22 feat(store-mapping): JD 缓存 deeplink 失效标记(对标淘宝) (#56)
Reviewed-on: #56
2026-06-16 01:25:57 +08:00
ouzhou f7d86011c1 feat(ad-revenue): admin 广告收益报表(按 用户/日期/类型/应用/代码位 聚合) (#54)
- 新增 GET /admin/api/ad-revenue-report:展示条数/收益 + 复用金币审计逐条复算做发奖对账
- ad_ecpm/ad_reward/ad_feed_reward 各加 app_env + our_code_id 两列(alembic 迁移)
- ecpm-report / feed-reward 接收并落库 app_env/our_code_id;激励发奖按 ad_session_id 回填
- ad_audit 抽出 audit_rows,报表与逐条审计复用同一复算口径
- 组级 matched 改「组内逐条全一致」,避免应发和==实发和的互相抵消掩盖错误
- list_feedbacks 改 offset 分页并返回 total(配合 admin 页码分页)
- 反馈正文上限 _CONTENT_MAX 2000→200
- 文档:新增 admin-ad-revenue-report,更新 ecpm/feed-reward/feedback 及对应 db docs

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

---------

Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #54
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-15 23:13:14 +08:00
marco 9ec9d2389d feat(store_mapping): 淘宝缓存 deeplink 失效检测→标记→回退(app-server 侧) (#53)
加 taobao_deeplink_invalid_at 列(迁移+model);mark_taobao_deeplink_invalid 按 shopId 标记所有行;lookup_nearest 过滤失效淘宝候选;新增 POST /internal/store-mapping/invalidate。+ 7 单测。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Reviewed-on: #53
2026-06-15 18:07:32 +08:00
liujiahui 8fa55eec3e fix(coupon): 领券弹窗频控按 App 独立 + debug 全重置补全 (#52)
bug: 弹窗 engagement 表只按 (device, 日) 全局记一条,美团弹过/领过就把整台设备当天
标记 engage,淘宝/京东被压住不弹。需求是美团/淘宝/京东各自独立、每日各弹一次。

改动:
- model: CouponPromptEngagement 加 package 列,唯一约束 (device,日) → (device,package,日)
- alembic: 新增迁移 coupon_engage_per_package(加列 + 改唯一约束, batch_alter_table)
- repository: has_engaged_today / mark_engagement 加 package 维度;新增 reset_today_completion
- api: should-show / dismiss 接收 package;coupon_step(step=0) 按 App 记 engagement;
  补 /prompt/shown 接口(客户端一直在调但后端缺失, 原 404);
  补 /completed-today/reset(开发设置全重置用, 解首页卡置灰)

验证: curl 端到端 —— 美团弹过后 should-show 美团=false 淘宝/京东=true;
shown/reset/completion-reset 端点全 ok。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: no_gen_mu <liujianhishen@gmail.com>
Reviewed-on: #52
Co-authored-by: liujiahui <liujiahui@wonderable.ai>
Co-committed-by: liujiahui <liujiahui@wonderable.ai>
2026-06-14 23:41:54 +08:00
ouzhou 27f76918b2 feat(admin): 运营后台跟进——review 加固 + 反馈改版 + 列表页码分页 (#51)
本 PR 汇合三块运营后台改动(原 #50 仅含其中「加固」一块,已并入本 PR 并关闭)。

## 1. review 加固 (436b2a3)
- 超管防自锁:降级/禁用最后一个 active super_admin 前校验,杜绝零超管死局
- 时间筛选统一 tz-aware(列为 timestamptz),比较绝对时刻、不依赖 DB 会话时区
- 上报审核 / 调余额(set·扣减)加行锁,防并发/连点重复发钱
- ad_audit 复算排序补 id 次级键;health-check 限 finance;调账/拒绝 reason 去空白校验

## 2. 反馈改版 (5a18dbb)
- contact 可选、截图≤6;admin 反馈列表筛选/排序;admin·wallet 接口调整 + docs

## 3. 列表页码分页 (1a7a624)
- CursorPage 加 total;新增 offset_paginate(count 与分页同源)
- 上报/审计日志从 id 游标改 offset 分页(支持跳页)
- 用户 / 提现 / 上报 / 审计日志 四页接入页码分页

测试:admin 套件 47 passed。前端配套改动见 shaguabijia-admin-web。

---------

Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #51
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-14 22:54:07 +08:00
liujiahui 47812f7fcc 比价结算记录 schema + comparison 模型 (#44)
## 改动
- 支撑客户端比价结算页:补 `compare_record` schema 字段 + `comparison` 模型调整
- docs/database 索引合并(领券三表 + onboarding_completion,表数对齐 28)

## 验证
- 模型/schema parse OK;已 merge origin/main 解 OVERVIEW/README 文档冲突(取并集)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: no_gen_mu <liujianhishen@gmail.com>
Reviewed-on: #44
Co-authored-by: liujiahui <liujiahui@wonderable.ai>
Co-committed-by: liujiahui <liujiahui@wonderable.ai>
2026-06-14 04:16:52 +08:00
marco 8659a7ed2b feat(store_mapping): 跨平台店铺映射表 + pricebot 内部上报端点 (#48)
新增「平台店铺表」资产层: 淘宝比价拿到 shopId 后, pricebot server→server 把跨平台
店铺身份(各平台 id/名 + 地理 + 来源)落库, 作为未来"我见过这家店→跳过重搜/匹配"的源头。
与 price_observation(价格事实)平行、独立。

- models/store_mapping.py: store_mapping 表 22 列 —— 跨平台身份(id/name_taobao/meituan/jd)
  + 地理(city/geohash/lng/lat/taobao_address) + 溯源(source_platform/trace_id/device/user)
  + 淘宝原料(share_url/resolved_url/deeplink) + attrs(JSONB) + created_at。
- schemas/store_mapping.py + repositories/store_mapping.py: append-only, trace_id 幂等
  (pricebot 重试/replay 不重复写; 并发 IntegrityError 兜底返已存在行), 跨方言安全。
- api/internal/store.py: POST /internal/store-mapping(复用 price.py 共享密钥 X-Internal-Secret 校验)。
- 注册 model(__init__) + router(main.py); 迁移 store_mapping_table 接现 head coin_txn_task_ref_uq。

验证: alembic 单 head + 零模型/迁移漂移; TestClient 全链(无密钥401/有密钥inserted=1/幂等inserted=0/错密钥401)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Reviewed-on: #48
2026-06-14 01:24:29 +08:00
ouzhou cce3a01de1 fix(marquee): 首页轮播脱敏名改为按 user_id 恒定 + 去 Faker 依赖 (#49)
Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #49
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-13 23:44:57 +08:00
79 changed files with 3133 additions and 327 deletions
@@ -0,0 +1,26 @@
"""merge store_mapping_jd_dl_invalid and ad_revenue heads (0.1.2)
Revision ID: 45047b5a884c
Revises: d4e68464761d, store_mapping_jd_dl_invalid
Create Date: 2026-06-16 01:38:26.273226
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '45047b5a884c'
down_revision: Union[str, Sequence[str], None] = ('d4e68464761d', 'store_mapping_jd_dl_invalid')
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 @@
"""ad revenue report columns: app_env + our_code_id
给 ad_ecpm_record / ad_reward_record / ad_feed_reward_record 各加两列:
- app_env:我们的穿山甲应用环境(prod=傻瓜比价正式 / test=测试应用)
- our_code_id:我们在穿山甲后台配置的代码位 ID(104xxx,非底层 mediation rit)
供「广告收益报表」按 用户/日期/广告类型/应用/代码位 聚合 展示条数/收益/金币。
旧数据这两列为 NULL(报表里来源列留空),新数据由客户端上报/发奖时回填。
Revision ID: ad_revenue_report_cols
Revises: coupon_engage_per_package
Create Date: 2026-06-15
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "ad_revenue_report_cols"
down_revision = "coupon_engage_per_package"
branch_labels = None
depends_on = None
_TABLES = ("ad_ecpm_record", "ad_reward_record", "ad_feed_reward_record")
def upgrade() -> None:
for table in _TABLES:
op.add_column(table, sa.Column("app_env", sa.String(length=16), nullable=True))
op.add_column(table, sa.Column("our_code_id", sa.String(length=64), nullable=True))
def downgrade() -> None:
for table in _TABLES:
op.drop_column(table, "our_code_id")
op.drop_column(table, "app_env")
@@ -0,0 +1,53 @@
"""coupon_prompt_engagement 频控加 package 维度(美团/淘宝/京东各自独立弹)
Revision ID: coupon_engage_per_package
Revises: store_mapping_jd_cols
Create Date: 2026-06-14 11:00:00.000000
需求:领券引导窗按 (device, App, 自然日) 频控——在美团弹过/领过,不影响淘宝、京东今天
仍各弹一次。原表唯一键是 (device_id, engage_date),缺 package → 任一 App 弹过就把整台
设备当天标记 engage,其余 App 被压住不弹(bug)。
本迁移:
1. 加 package 列(NOT NULL,旧行用 server_default "" 填占位,不影响新逻辑判断)。
2. 旧唯一约束 (device_id, engage_date) → 新 (device_id, package, engage_date)。
SQLite 不支持直接 drop/add 约束,用 batch_alter_table(建临时表 + 拷数据 + 换名,
与 store_mapping_* 同款)。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'coupon_engage_per_package'
down_revision: Union[str, Sequence[str], None] = 'store_mapping_jd_cols'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('coupon_prompt_engagement', schema=None) as batch_op:
# 加 package 列。旧行(改造前的全局记录)填 "" 占位:它们对应的是"老的全局态",
# 新逻辑按 (device, package, 日) 判,占位 "" 不会与真实包名(com.xxx)碰撞。
batch_op.add_column(
sa.Column('package', sa.String(length=64), nullable=False, server_default='')
)
# 旧唯一约束 (device_id, engage_date) → 新三元组 (device_id, package, engage_date)。
batch_op.drop_constraint('uq_coupon_engage_device_date', type_='unique')
batch_op.create_unique_constraint(
'uq_coupon_engage_device_pkg_date',
['device_id', 'package', 'engage_date'],
)
def downgrade() -> None:
with op.batch_alter_table('coupon_prompt_engagement', schema=None) as batch_op:
batch_op.drop_constraint('uq_coupon_engage_device_pkg_date', type_='unique')
batch_op.create_unique_constraint(
'uq_coupon_engage_device_date',
['device_id', 'engage_date'],
)
batch_op.drop_column('package')
@@ -0,0 +1,26 @@
"""merge ad_revenue_report_cols and store_mapping_tb_dl_invalid heads
Revision ID: d4e68464761d
Revises: ad_revenue_report_cols, store_mapping_tb_dl_invalid
Create Date: 2026-06-15 21:55:58.115692
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd4e68464761d'
down_revision: Union[str, Sequence[str], None] = ('ad_revenue_report_cols', 'store_mapping_tb_dl_invalid')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
+40
View File
@@ -0,0 +1,40 @@
"""store_mapping 加京东原料列(jd_vender_id + 分享链/反查 URL/deeplink)
Revision ID: store_mapping_jd_cols
Revises: store_mapping_meituan_cols
Create Date: 2026-06-13 00:00:00.000000
京东秒送接入店内搜索 deeplink 链路: 3.cn 短链 → 跟随重定向反查 venderId+storeId →
openapp.jdmobile:// deeplink。京东店铺身份是**两个**稳定数字 id: storeId 进 id_jd(稳定
店主键, 同 taobao 的 shopId→id_taobao), venderId 进单列 jd_vender_id(deeplink 还需它)。
其余三列与 taobao_*/meituan_* 原料列平行。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'store_mapping_jd_cols'
down_revision: Union[str, Sequence[str], None] = 'store_mapping_meituan_cols'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.add_column(sa.Column('jd_vender_id', sa.String(length=64), nullable=True))
batch_op.add_column(sa.Column('jd_share_url', sa.String(length=256), nullable=True))
batch_op.add_column(sa.Column('jd_resolved_url', sa.Text(), nullable=True))
batch_op.add_column(sa.Column('jd_deeplink', sa.Text(), nullable=True))
batch_op.create_index(batch_op.f('ix_store_mapping_jd_vender_id'), ['jd_vender_id'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_store_mapping_jd_vender_id'))
batch_op.drop_column('jd_deeplink')
batch_op.drop_column('jd_resolved_url')
batch_op.drop_column('jd_share_url')
batch_op.drop_column('jd_vender_id')
@@ -0,0 +1,32 @@
"""store_mapping 加京东 deeplink 失效标记列 jd_deeplink_invalid_at
Revision ID: store_mapping_jd_dl_invalid
Revises: store_mapping_tb_dl_invalid
Create Date: 2026-06-15 00:00:00.000000
京东同淘宝: 缓存的店内搜索 deeplink 会失效(打开是"当前门店超出配送范围"页)。pricebot 比价撞到
失效页时回退正常搜店, 并 server→server 通知把该 storeId 的 deeplink 标记失效。本列记失效时刻
(NULL=有效); lookup 反查时过滤掉已失效的京东候选, 不再返回坏 deeplink。
与淘宝 taobao_deeplink_invalid_at 对称; 用时间戳而非布尔: 留痕可审计、可统计失效率, 不销毁原 deeplink。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'store_mapping_jd_dl_invalid'
down_revision: Union[str, Sequence[str], None] = 'store_mapping_tb_dl_invalid'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.add_column(sa.Column('jd_deeplink_invalid_at', sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.drop_column('jd_deeplink_invalid_at')
@@ -0,0 +1,39 @@
"""store_mapping 加美团原料列(poi_id_str + 分享链/反查 URL/deeplink)
Revision ID: store_mapping_meituan_cols
Revises: store_mapping_table
Create Date: 2026-06-13 00:00:00.000000
美团接入店内搜索 deeplink 链路: dpurl.cn 短链 → 302 反查 poi_id_str → imeituan:// deeplink。
poi_id_str 每次分享重新加密、非稳定主键, 单列 meituan_poi_id_str 存(不占 id_meituan,
后者留给将来 CPS API 的稳定数字 poi_id)。其余三列与 taobao_* 原料列平行。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'store_mapping_meituan_cols'
down_revision: Union[str, Sequence[str], None] = 'store_mapping_table'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.add_column(sa.Column('meituan_poi_id_str', sa.String(length=64), nullable=True))
batch_op.add_column(sa.Column('meituan_share_url', sa.String(length=256), nullable=True))
batch_op.add_column(sa.Column('meituan_resolved_url', sa.Text(), nullable=True))
batch_op.add_column(sa.Column('meituan_deeplink', sa.Text(), nullable=True))
batch_op.create_index(batch_op.f('ix_store_mapping_meituan_poi_id_str'), ['meituan_poi_id_str'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_store_mapping_meituan_poi_id_str'))
batch_op.drop_column('meituan_deeplink')
batch_op.drop_column('meituan_resolved_url')
batch_op.drop_column('meituan_share_url')
batch_op.drop_column('meituan_poi_id_str')
+77
View File
@@ -0,0 +1,77 @@
"""store_mapping table (平台店铺表:跨平台同店 id/名 映射,server 侧无条件落库)
Revision ID: store_mapping_table
Revises: coin_txn_task_ref_uq
Create Date: 2026-06-13 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'store_mapping_table'
down_revision: Union[str, Sequence[str], None] = 'coin_txn_task_ref_uq'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'store_mapping',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
# 跨平台身份
sa.Column('id_taobao', sa.String(length=64), nullable=True),
sa.Column('name_taobao', sa.String(length=128), nullable=True),
sa.Column('id_meituan', sa.String(length=64), nullable=True),
sa.Column('name_meituan', sa.String(length=128), nullable=True),
sa.Column('id_jd', sa.String(length=64), nullable=True),
sa.Column('name_jd', sa.String(length=128), nullable=True),
# 地理
sa.Column('city', sa.String(length=64), nullable=True),
sa.Column('geohash', sa.String(length=16), nullable=True),
sa.Column('lng', sa.Float(), nullable=True),
sa.Column('lat', sa.Float(), nullable=True),
sa.Column('taobao_address', sa.String(length=256), nullable=True),
# 溯源
sa.Column('source_platform', sa.String(length=32), nullable=True),
sa.Column('business_type', sa.String(length=16), nullable=False),
sa.Column('trace_id', sa.String(length=64), nullable=False),
sa.Column('source_device_id', sa.String(length=64), nullable=True),
sa.Column('source_user_id', sa.Integer(), nullable=True),
# 淘宝原料(URL 可能很长 → Text)
sa.Column('taobao_share_url', sa.String(length=256), nullable=True),
sa.Column('taobao_resolved_url', sa.Text(), nullable=True),
sa.Column('taobao_deeplink', sa.Text(), nullable=True),
# PG 上为 JSONB,其它(SQLite)为 JSON——与模型层 with_variant 对齐
sa.Column('attrs', sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), 'postgresql'), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id'),
# 一次比价一行,trace_id 幂等去重(防 pricebot 重试 / replay 重复写)
sa.UniqueConstraint('trace_id', name='uq_store_mapping_trace'),
)
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_store_mapping_id_taobao'), ['id_taobao'], unique=False)
batch_op.create_index(batch_op.f('ix_store_mapping_id_meituan'), ['id_meituan'], unique=False)
batch_op.create_index(batch_op.f('ix_store_mapping_id_jd'), ['id_jd'], unique=False)
batch_op.create_index(batch_op.f('ix_store_mapping_geohash'), ['geohash'], unique=False)
batch_op.create_index(batch_op.f('ix_store_mapping_source_platform'), ['source_platform'], unique=False)
batch_op.create_index(batch_op.f('ix_store_mapping_source_device_id'), ['source_device_id'], unique=False)
batch_op.create_index(batch_op.f('ix_store_mapping_source_user_id'), ['source_user_id'], unique=False)
batch_op.create_index(batch_op.f('ix_store_mapping_created_at'), ['created_at'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_store_mapping_created_at'))
batch_op.drop_index(batch_op.f('ix_store_mapping_source_user_id'))
batch_op.drop_index(batch_op.f('ix_store_mapping_source_device_id'))
batch_op.drop_index(batch_op.f('ix_store_mapping_source_platform'))
batch_op.drop_index(batch_op.f('ix_store_mapping_geohash'))
batch_op.drop_index(batch_op.f('ix_store_mapping_id_jd'))
batch_op.drop_index(batch_op.f('ix_store_mapping_id_meituan'))
batch_op.drop_index(batch_op.f('ix_store_mapping_id_taobao'))
op.drop_table('store_mapping')
@@ -0,0 +1,33 @@
"""store_mapping 加淘宝 deeplink 失效标记列 taobao_deeplink_invalid_at
Revision ID: store_mapping_tb_dl_invalid
Revises: coupon_engage_per_package
Create Date: 2026-06-15 00:00:00.000000
缓存的淘宝店内搜索 deeplink 会失效(打开是"页面出错了"降级页)。pricebot 比价撞到错误页时
回退正常搜店, 并 server→server 通知把该 shopId 的 deeplink 标记失效。本列记失效时刻
(NULL=有效); lookup 反查时过滤掉已失效的淘宝候选, 不再返回坏 deeplink。
只加淘宝一列(当前只接淘宝); 用时间戳而非布尔: 留痕可审计、可统计失效率, 且不销毁原 deeplink。
无需索引: 过滤总叠在 name_taobao== 之后, 候选集已小。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'store_mapping_tb_dl_invalid'
down_revision: Union[str, Sequence[str], None] = 'coupon_engage_per_package'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.add_column(sa.Column('taobao_deeplink_invalid_at', sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.drop_column('taobao_deeplink_invalid_at')
+2
View File
@@ -14,6 +14,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.admin.routers.ad_audit import router as ad_audit_router from app.admin.routers.ad_audit import router as ad_audit_router
from app.admin.routers.ad_revenue import router as ad_revenue_router
from app.admin.routers.admins import router as admins_router from app.admin.routers.admins import router as admins_router
from app.admin.routers.audit import router as audit_router from app.admin.routers.audit import router as audit_router
from app.admin.routers.auth import router as auth_router from app.admin.routers.auth import router as auth_router
@@ -90,3 +91,4 @@ admin_app.include_router(admins_router)
admin_app.include_router(audit_router) admin_app.include_router(audit_router)
admin_app.include_router(config_router) admin_app.include_router(config_router)
admin_app.include_router(ad_audit_router) admin_app.include_router(ad_audit_router)
admin_app.include_router(ad_revenue_router)
+98 -18
View File
@@ -1,16 +1,18 @@
"""看广告金币审计:复算 expected_coin 并与实发对比。 """看广告金币审计:复算 expected_coin 并与实发对比。
只读。复用 [app.core.rewards] 的公式函数(不另写公式,避免与正式发奖口径漂移): 只读。复用 [app.core.rewards] 的公式函数(不另写公式,避免与正式发奖口径漂移):
- 看视频:每条 granted = 1 份,第 N 份 = 当日该用户 granted 的 reward_video 顺序号 - 看视频:每条 granted = 1 份,第 N 份 = 该用户 granted 的 reward_video **账号累计**顺序号
(与 ad_reward.grant_ad_reward 里 `_granted_today + 1` 一致)。 (与 ad_reward.grant_ad_reward 里 `_granted_cumulative + 1` 一致;LT 因子不按天重置,
- 信息流:每条按 unit_count 份逐份累加,LT 序号 = 当日该用户已 granted 份数累计 故复算时要把当日序号叠加上该用户在本日**之前**的累计已发份数)。
(与 ad_feed_reward._unit_reward_total 的 existing_units 一致)。 - 信息流:每条按 unit_count 份逐份累加,LT 序号 = 该用户 granted 份数**账号累计**
(与 ad_feed_reward._unit_reward_total 的 existing_units 一致;同样不按天重置,
复算需叠加本日之前的累计份数)。
非 granted(capped/ecpm_missing)不占用份序号、应发恒 0,据此校验闸口是否确实没发。 非 granted(capped/ecpm_missing)不占用份序号、应发恒 0,据此校验闸口是否确实没发。
""" """
from __future__ import annotations from __future__ import annotations
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core import rewards from app.core import rewards
@@ -19,22 +21,42 @@ from app.models.ad_reward import AdRewardRecord
from app.repositories.ad_feed_reward import FEED_REWARD_UNIT_SECONDS from app.repositories.ad_feed_reward import FEED_REWARD_UNIT_SECONDS
def _prior_granted_counts(
db: Session, *, date: str, user_id: int | None
) -> dict[int, int]:
"""各用户在 date **之前**已发奖的 reward_video 累计份数,作为当日复算的 LT 序号起点。
LT 因子改账号累计后,当日第 1 份并非全局第 1 份,需叠加历史累计。"""
stmt = (
select(AdRewardRecord.user_id, func.count())
.where(
AdRewardRecord.reward_date < date,
AdRewardRecord.reward_scene == "reward_video",
AdRewardRecord.status == "granted",
)
.group_by(AdRewardRecord.user_id)
)
if user_id is not None:
stmt = stmt.where(AdRewardRecord.user_id == user_id)
return {uid: n for uid, n in db.execute(stmt).all()}
def _reward_video_rows( def _reward_video_rows(
db: Session, *, date: str, user_id: int | None db: Session, *, date: str, user_id: int | None
) -> list[dict]: ) -> list[dict]:
"""看视频记录复算。按 (user_id, created_at) 升序还原当日第 N 份。""" """看视频记录复算。按 (user_id, created_at) 升序还原账号累计第 N 份(含本日之前的累计)"""
stmt = ( stmt = (
select(AdRewardRecord) select(AdRewardRecord)
.where( .where(
AdRewardRecord.reward_date == date, AdRewardRecord.reward_date == date,
AdRewardRecord.reward_scene == "reward_video", AdRewardRecord.reward_scene == "reward_video",
) )
.order_by(AdRewardRecord.user_id, AdRewardRecord.created_at) .order_by(AdRewardRecord.user_id, AdRewardRecord.created_at, AdRewardRecord.id)
) )
if user_id is not None: if user_id is not None:
stmt = stmt.where(AdRewardRecord.user_id == user_id) stmt = stmt.where(AdRewardRecord.user_id == user_id)
granted_n: dict[int, int] = {} # user_id -> 已 granted 份数 # 用本日之前的累计份数做起点,当日 granted 在其上继续递增 → 与 _granted_cumulative+1 对齐
granted_n: dict[int, int] = _prior_granted_counts(db, date=date, user_id=user_id)
rows: list[dict] = [] rows: list[dict] = []
for rec in db.execute(stmt).scalars(): for rec in db.execute(stmt).scalars():
if rec.status == "granted": if rec.status == "granted":
@@ -45,6 +67,8 @@ def _reward_video_rows(
"scene": "reward_video", "scene": "reward_video",
"record_id": rec.id, "record_id": rec.id,
"user_id": rec.user_id, "user_id": rec.user_id,
"app_env": rec.app_env,
"our_code_id": rec.our_code_id,
"created_at": rec.created_at, "created_at": rec.created_at,
"status": rec.status, "status": rec.status,
"ecpm": rec.ecpm_raw, "ecpm": rec.ecpm_raw,
@@ -64,6 +88,8 @@ def _reward_video_rows(
"scene": "reward_video", "scene": "reward_video",
"record_id": rec.id, "record_id": rec.id,
"user_id": rec.user_id, "user_id": rec.user_id,
"app_env": rec.app_env,
"our_code_id": rec.our_code_id,
"created_at": rec.created_at, "created_at": rec.created_at,
"status": rec.status, "status": rec.status,
"ecpm": rec.ecpm_raw, "ecpm": rec.ecpm_raw,
@@ -80,17 +106,38 @@ def _reward_video_rows(
return rows return rows
def _feed_prior_granted_units(
db: Session, *, date: str, user_id: int | None
) -> dict[int, int]:
"""各用户在 date **之前** granted 的信息流份数累计,作为当日复算的 LT 序号起点。"""
stmt = (
select(
AdFeedRewardRecord.user_id,
func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0),
)
.where(
AdFeedRewardRecord.reward_date < date,
AdFeedRewardRecord.status == "granted",
)
.group_by(AdFeedRewardRecord.user_id)
)
if user_id is not None:
stmt = stmt.where(AdFeedRewardRecord.user_id == user_id)
return {uid: int(n) for uid, n in db.execute(stmt).all()}
def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]: def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
"""信息流记录复算。granted 记录逐份累加,LT 序号沿用当日累计份数。""" """信息流记录复算。granted 记录逐份累加,LT 序号沿用账号累计份数(含本日之前)"""
stmt = ( stmt = (
select(AdFeedRewardRecord) select(AdFeedRewardRecord)
.where(AdFeedRewardRecord.reward_date == date) .where(AdFeedRewardRecord.reward_date == date)
.order_by(AdFeedRewardRecord.user_id, AdFeedRewardRecord.created_at) .order_by(AdFeedRewardRecord.user_id, AdFeedRewardRecord.created_at, AdFeedRewardRecord.id)
) )
if user_id is not None: if user_id is not None:
stmt = stmt.where(AdFeedRewardRecord.user_id == user_id) stmt = stmt.where(AdFeedRewardRecord.user_id == user_id)
granted_units: dict[int, int] = {} # user_id -> 已 granted 份数累计 # 本日之前的累计份数做起点,与 _unit_reward_total 的 existing_units(累计)对齐
granted_units: dict[int, int] = _feed_prior_granted_units(db, date=date, user_id=user_id)
rows: list[dict] = [] rows: list[dict] = []
for rec in db.execute(stmt).scalars(): for rec in db.execute(stmt).scalars():
if rec.status == "granted": if rec.status == "granted":
@@ -107,6 +154,8 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
"scene": "feed", "scene": "feed",
"record_id": rec.id, "record_id": rec.id,
"user_id": rec.user_id, "user_id": rec.user_id,
"app_env": rec.app_env,
"our_code_id": rec.our_code_id,
"created_at": rec.created_at, "created_at": rec.created_at,
"status": rec.status, "status": rec.status,
"ecpm": rec.ecpm_raw, "ecpm": rec.ecpm_raw,
@@ -125,6 +174,8 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
"scene": "feed", "scene": "feed",
"record_id": rec.id, "record_id": rec.id,
"user_id": rec.user_id, "user_id": rec.user_id,
"app_env": rec.app_env,
"our_code_id": rec.our_code_id,
"created_at": rec.created_at, "created_at": rec.created_at,
"status": rec.status, "status": rec.status,
"ecpm": rec.ecpm_raw, "ecpm": rec.ecpm_raw,
@@ -141,21 +192,50 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
return rows return rows
def ad_coin_audit( def audit_rows(
db: Session, *, date: str, user_id: int | None, scene: str | None, limit: int db: Session, *, date: str, user_id: int | None, scene: str | None = None
) -> list[dict]: ) -> list[dict]:
"""返回当日发奖复算明细,按 created_at 倒序(最新在前)截断到 limit """当日逐条发奖复算行(未排序)。scene: None=两类 / "reward_video" / "feed"
scene: None=两类都要 / "reward_video" / "feed" 每行含 `app_env`/`our_code_id`/`expected_coin`/`actual_coin` 等,供金币审计逐条对账,
份序号在截断前已基于全天数据算好,故 limit 只影响展示条数、不影响 expected 复算正确性 也供广告收益报表把「应发/实发」按 用户×类型×应用×代码位 聚合(见 ad_revenue,复用同一复算口径)
""" """
rows: list[dict] = [] rows: list[dict] = []
if scene in (None, "reward_video"): if scene in (None, "reward_video"):
rows.extend(_reward_video_rows(db, date=date, user_id=user_id)) rows.extend(_reward_video_rows(db, date=date, user_id=user_id))
if scene in (None, "feed"): if scene in (None, "feed"):
rows.extend(_feed_rows(db, date=date, user_id=user_id)) rows.extend(_feed_rows(db, date=date, user_id=user_id))
rows.sort(key=lambda r: r["created_at"], reverse=True) return rows
return rows[:limit]
def ad_coin_audit(
db: Session,
*,
date: str,
user_id: int | None,
scene: str | None,
limit: int,
only_mismatch: bool = False,
) -> dict:
"""当日发奖复算。返回 {total, mismatch_count, truncated, items}。
scene: None=两类都要 / "reward_video" / "feed";only_mismatch=True 只展示不一致(✗)行。
关键:`total` 与 `mismatch_count` 在**全量**(截断前)上统计,故对账数字始终可信,不受 limit
影响;`items` 才是展示集(only_mismatch 时只取 ✗ 行)按 created_at 倒序截断到 limit。
份序号在全天数据上已算好,limit 只影响展示条数、不影响 expected 复算正确性。
"""
rows = audit_rows(db, date=date, user_id=user_id, scene=scene)
rows.sort(key=lambda r: (r["created_at"], r["record_id"]), reverse=True)
total = len(rows)
mismatch_count = sum(1 for r in rows if not r["matched"])
display = [r for r in rows if not r["matched"]] if only_mismatch else rows
return {
"total": total,
"mismatch_count": mismatch_count,
"truncated": len(display) > limit,
"items": display[:limit],
}
def formula_snapshot() -> dict: def formula_snapshot() -> dict:
+239
View File
@@ -0,0 +1,239 @@
"""admin 广告收益报表:按 用户 / 日期 / 广告类型 / 应用 / 代码位 聚合(单表含发奖对账)。
只读。聚合键 = user_id × ad_type × app_env × our_code_id;每组一行同时给出:
- 展示条数 + 收益:`ad_ecpm_record`(每行 = 客户端一次广告展示;收益 = Σ eCPM元 ÷ 1000)。
激励视频每次展示上报一行;信息流轮播每条展示各上报一行(每条独立 id,不复用会话)。
- 应发金币 / 实发金币:复用金币审计的**逐条复算**(`ad_audit.audit_rows`,与正式发奖同一公式口径,
不另写公式),把每条发奖记录的 expected/actual 按同维度求和;`matched` = 组内**逐条**全部一致
(任一条不符该组即不符,不用「应发和==实发和」以免互相抵消掩盖错误)。**不改发奖逻辑**,只读复算。
展示与发奖来自不同表,做并集:有展示无发奖(用户中途关 / 未达发奖)、有发奖无展示
(未上报 eCPM)都各自成行。app_env/our_code_id 旧数据为 NULL → 归到「来源未知」组。
⚠️ 局限:① 历史 Draw 发奖混在 ad_feed_reward_record 无类型标记,金币侧统一记 `feed`(迁移后 Draw
不再产生新数据)。② 聚合级只能看出「某组应发≠实发」,定位到具体哪条仍需逐条审计接口(ad-coin-audit)。
"""
from __future__ import annotations
from datetime import date as _date, datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.admin.repositories import ad_audit
from app.core import rewards
from app.models.ad_ecpm import AdEcpmRecord
def _cn_hour(dt: datetime) -> int:
"""created_at(UTC 口径)→ 北京时间小时(023)。naive 当 UTC 处理(sqlite),tz-aware 直接换算(pg)。"""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(rewards.CN_TZ).hour
def _key(
report_date: str,
user_id: int,
ad_type: str,
app_env: str | None,
our_code_id: str | None,
hour: int | None,
) -> tuple:
return (report_date, user_id, ad_type, app_env or None, our_code_id or None, hour)
def _date_range(date_from: str, date_to: str) -> list[str]:
"""闭区间内逐日 'YYYY-MM-DD' 串(含首尾)。date_from > date_to 时返回空。"""
d0 = _date.fromisoformat(date_from)
d1 = _date.fromisoformat(date_to)
out: list[str] = []
d = d0
while d <= d1:
out.append(d.isoformat())
d += timedelta(days=1)
return out
# 审计行的 scene 与报表 ad_type 一一对应
_SCENE_TO_AD_TYPE = {"reward_video": "reward_video", "feed": "feed"}
def ad_revenue_report(
db: Session,
*,
date_from: str,
date_to: str,
user_id: int | None = None,
ad_type: str | None = None,
granularity: str = "day",
limit: int = 500,
) -> dict:
"""日期区间(北京时间,闭区间)广告收益聚合 + 发奖对账。单日时 date_from==date_to。
聚合键含**日期**:report_date × user × ad_type × app_env × our_code_id(× 北京小时,granularity=hour)。
ad_type: None=全部 / reward_video / feed / draw。
granularity: "day"=按天 / "hour"=按小时(聚合键再加北京小时 0–23,每组一行)。
limit 只截断展示明细,total 与 total_* / daily 在全量上统计(不受 limit 影响),数字始终可信。
返回额外含 `daily`(按日期汇总的展示/收益/应发/实发,供前端按天趋势图;不受 limit 影响)。
注:按小时下,展示按 ecpm 记录的小时、金币按发奖记录的小时各自归桶——S2S 回调可能比展示晚
一会儿,故同一次广告的展示与金币偶尔落相邻小时(按天则一致)。
"""
by_hour = granularity == "hour"
groups: dict[tuple, dict] = {}
def _grp(key: tuple) -> dict:
g = groups.get(key)
if g is None:
rdate, uid, atype, app_env, code_id, hour = key
g = {
"report_date": rdate,
"user_id": uid,
"ad_type": atype,
"app_env": app_env,
"our_code_id": code_id,
"hour": hour,
"impressions": 0,
"revenue_yuan": 0.0,
"expected_coin": 0,
"actual_coin": 0,
"adns": set(),
"impression_records": [], # 该组逐条展示明细(展开下钻用)
"records": [], # 该组逐条发奖复算明细(展开下钻用)
}
groups[key] = g
return g
# 1) 展示条数 + 收益 ← ad_ecpm_record(report_date 闭区间;字符串 YYYY-MM-DD 字典序即日期序)
stmt = select(AdEcpmRecord).where(
AdEcpmRecord.report_date >= date_from,
AdEcpmRecord.report_date <= date_to,
)
if user_id is not None:
stmt = stmt.where(AdEcpmRecord.user_id == user_id)
if ad_type is not None:
stmt = stmt.where(AdEcpmRecord.ad_type == ad_type)
for rec in db.execute(stmt).scalars():
hour = _cn_hour(rec.created_at) if by_hour else None
g = _grp(_key(rec.report_date, rec.user_id, rec.ad_type, rec.app_env, rec.our_code_id, hour))
g["impressions"] += 1
# 单次展示收益(元) = eCPM元 ÷ 1000(每千次→单次);用与发奖同源的解析,口径一致。
rev = rewards.parse_ecpm_yuan(rec.ecpm_raw) / 1000.0
g["revenue_yuan"] += rev
if rec.adn:
g["adns"].add(rec.adn)
g["impression_records"].append({
"id": rec.id,
"created_at": rec.created_at,
"ecpm": rec.ecpm_raw,
"revenue_yuan": round(rev, 6),
"adn": rec.adn,
"slot_id": rec.slot_id,
})
# 2) 应发 / 实发金币 ← 复用金币审计逐条复算(同一公式口径),按同维度求和。
# audit_rows 是单日的,区间逐日调用,每天的行归到当天 report_date(语义与单日报表完全一致)。
# ad_type=draw 时审计无对应记录(scene 只有 reward_video/feed),金币侧自然为空。
audit_scene = _SCENE_TO_AD_TYPE.get(ad_type) if ad_type is not None else None
if ad_type is None or audit_scene is not None:
for d in _date_range(date_from, date_to):
for row in ad_audit.audit_rows(db, date=d, user_id=user_id, scene=audit_scene):
atype = _SCENE_TO_AD_TYPE.get(row["scene"], row["scene"])
hour = _cn_hour(row["created_at"]) if by_hour else None
g = _grp(_key(d, row["user_id"], atype, row.get("app_env"), row.get("our_code_id"), hour))
g["expected_coin"] += int(row["expected_coin"])
g["actual_coin"] += int(row["actual_coin"])
# 逐条明细(eCPM/因子1/份数/LT/因子2/应发/实发/一致)——前端展开该组时下钻展示。
g["records"].append({
"record_id": row["record_id"],
"created_at": row["created_at"],
"status": row["status"],
"ecpm": row["ecpm"],
"ecpm_factor": row["ecpm_factor"],
"units": row["units"],
"lt_index_start": row["lt_index_start"],
"lt_index_end": row["lt_index_end"],
"lt_factor_start": row["lt_factor_start"],
"lt_factor_end": row["lt_factor_end"],
"expected_coin": row["expected_coin"],
"actual_coin": row["actual_coin"],
"matched": row["matched"],
})
rows = list(groups.values())
rows.sort(
key=lambda r: (
r["report_date"],
r["user_id"],
r["hour"] if r["hour"] is not None else -1,
r["ad_type"] or "",
r["our_code_id"] or "",
)
)
total_impressions = sum(r["impressions"] for r in rows)
total_expected_coin = sum(r["expected_coin"] for r in rows)
total_actual_coin = sum(r["actual_coin"] for r in rows)
total_revenue_yuan = round(sum(r["revenue_yuan"] for r in rows), 6)
# 按日期汇总(全量,不受 limit):供前端按天趋势图。
daily_map: dict[str, dict] = {}
for r in rows:
d = daily_map.get(r["report_date"])
if d is None:
d = {
"date": r["report_date"],
"impressions": 0,
"revenue_yuan": 0.0,
"expected_coin": 0,
"actual_coin": 0,
}
daily_map[r["report_date"]] = d
d["impressions"] += r["impressions"]
d["revenue_yuan"] += r["revenue_yuan"]
d["expected_coin"] += r["expected_coin"]
d["actual_coin"] += r["actual_coin"]
daily = [
{**d, "revenue_yuan": round(d["revenue_yuan"], 6)}
for d in sorted(daily_map.values(), key=lambda x: x["date"])
]
items = [
{
"report_date": r["report_date"],
"user_id": r["user_id"],
"ad_type": r["ad_type"],
"app_env": r["app_env"],
"our_code_id": r["our_code_id"],
"hour": r["hour"],
"impressions": r["impressions"],
"revenue_yuan": round(r["revenue_yuan"], 6),
"expected_coin": r["expected_coin"],
"actual_coin": r["actual_coin"],
# 组内**逐条**全部一致才记一致——不能用「应发和==实发和」,否则一条多发+一条少发会互相
# 抵消、求和相等被误判为 ✓,掩盖真实发奖错误。纯展示无发奖记录的组 all([]) → True。
"matched": all(rec["matched"] for rec in r["records"]),
"adns": sorted(r["adns"]),
"impression_records": sorted(
r["impression_records"], key=lambda x: (x["created_at"], x["id"])
),
"records": sorted(r["records"], key=lambda x: (x["created_at"], x["record_id"])),
}
for r in rows[:limit]
]
return {
"total": len(rows),
"truncated": len(rows) > limit,
"total_impressions": total_impressions,
"total_revenue_yuan": total_revenue_yuan,
"total_expected_coin": total_expected_coin,
"total_actual_coin": total_actual_coin,
"mismatch_count": sum(
1 for r in rows if not all(rec["matched"] for rec in r["records"])
),
"daily": daily,
"items": items,
}
+14 -11
View File
@@ -4,7 +4,7 @@
""" """
from __future__ import annotations from __future__ import annotations
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.admin import AdminAuditLog from app.models.admin import AdminAuditLog
@@ -49,8 +49,9 @@ def list_audit_logs(
admin_id: int | None = None, admin_id: int | None = None,
limit: int = 50, limit: int = 50,
cursor: int | None = None, cursor: int | None = None,
) -> tuple[list[AdminAuditLog], int | None]: ) -> tuple[list[AdminAuditLog], int | None, int]:
"""游标分页(id 倒序),与现有 list_* 约定一致。返回 (rows, next_cursor)。""" """offset 分页(id 倒序)+ total。cursor 即 offset((page-1)*pageSize),支持页码跳页。
返回 (rows, next_cursor, total)"""
stmt = select(AdminAuditLog) stmt = select(AdminAuditLog)
if action: if action:
stmt = stmt.where(AdminAuditLog.action == action) stmt = stmt.where(AdminAuditLog.action == action)
@@ -58,13 +59,15 @@ def list_audit_logs(
stmt = stmt.where(AdminAuditLog.target_type == target_type) stmt = stmt.where(AdminAuditLog.target_type == target_type)
if admin_id is not None: if admin_id is not None:
stmt = stmt.where(AdminAuditLog.admin_id == admin_id) stmt = stmt.where(AdminAuditLog.admin_id == admin_id)
if cursor is not None:
stmt = stmt.where(AdminAuditLog.id < cursor) total = int(db.execute(select(func.count()).select_from(stmt.subquery())).scalar_one())
stmt = stmt.order_by(AdminAuditLog.id.desc()) offset = max(cursor or 0, 0)
rows = list(db.execute(stmt.limit(limit + 1)).scalars().all()) rows = list(
db.execute(
stmt.order_by(AdminAuditLog.id.desc()).offset(offset).limit(limit + 1)
).scalars().all()
)
has_more = len(rows) > limit has_more = len(rows) > limit
items = rows[:limit] items = rows[:limit]
# next_cursor 必须是"本页返回的最后一条"的 id(下一页查 id < 它),不能用 rows[limit]—— next_cursor = offset + limit if has_more else None
# rows[limit] 是探测下一页用的第 limit+1 条,它既不在本页也不在下页 → 每页边界丢一条。 return items, next_cursor, total
next_cursor = items[-1].id if has_more else None
return items, next_cursor
+94 -24
View File
@@ -38,15 +38,48 @@ def cursor_paginate(
return items, next_cursor return items, next_cursor
def offset_paginate(
db: Session, stmt: Select, sort_clause: tuple, *, limit: int, cursor: int | None
) -> tuple[list, int | None, int]:
"""offset 分页 + 总数。stmt 只含 where/join,不要预先带 order_by/offset/limit。
cursor offset(页码分页:offset=(page-1)*pageSize)返回 (items, next_cursor, total):
- total:符合筛选条件的总条数( antd pagination 渲染页码/ N ),count P0 量级开销可忽略;
- next_cursor:下一页 offset(兼容加载更多),末页为 None
多取 1 条探测下一页sort_clause order_by 表达式元组(末位应含 id 保证稳定排序)"""
total = int(
db.execute(select(func.count()).select_from(stmt.subquery())).scalar_one()
)
offset = max(cursor or 0, 0)
rows = list(
db.execute(stmt.order_by(*sort_clause).offset(offset).limit(limit + 1)).scalars().all()
)
has_more = len(rows) > limit
items = rows[:limit]
next_cursor = offset + limit if has_more else None
return items, next_cursor, total
def list_users( def list_users(
db: Session, db: Session,
*, *,
phone: str | None = None, phone: str | None = None,
register_channel: str | None = None, register_channel: str | None = None,
status: str | None = None, status: str | None = None,
nickname: str | None = None,
created_from: datetime | None = None,
created_to: datetime | None = None,
last_login_from: datetime | None = None,
last_login_to: datetime | None = None,
sort_by: str = "id",
sort_order: str = "desc",
limit: int = 20, limit: int = 20,
cursor: int | None = None, cursor: int | None = None,
) -> tuple[list[User], int | None]: ) -> tuple[list[User], int | None, int]:
"""用户列表(admin 全量)。支持手机号前缀 / 渠道 / 状态 / 昵称模糊 / 注册·最近登录时间范围筛选,
id·注册时间·最近登录排序**offset 分页**(cursor=offset):任意列排序下游标语义统一,
代价是翻页期间数据变动可能错位一条admin 低频场景可接受( [list_all_withdraw_orders])
日期入参统一转 tz-aware UTC 比较(列为 timestamptz, _as_utc)"""
stmt = select(User) stmt = select(User)
if phone: if phone:
stmt = stmt.where(User.phone.like(f"{phone}%")) # 前缀匹配 stmt = stmt.where(User.phone.like(f"{phone}%")) # 前缀匹配
@@ -54,7 +87,26 @@ def list_users(
stmt = stmt.where(User.register_channel == register_channel) stmt = stmt.where(User.register_channel == register_channel)
if status: if status:
stmt = stmt.where(User.status == status) stmt = stmt.where(User.status == status)
return cursor_paginate(db, stmt, User.id, limit=limit, cursor=cursor) if nickname and nickname.strip():
stmt = stmt.where(User.nickname.ilike(f"%{nickname.strip()}%"))
if created_from is not None:
stmt = stmt.where(User.created_at >= _as_utc(created_from))
if created_to is not None:
stmt = stmt.where(User.created_at <= _as_utc(created_to))
if last_login_from is not None:
stmt = stmt.where(User.last_login_at >= _as_utc(last_login_from))
if last_login_to is not None:
stmt = stmt.where(User.last_login_at <= _as_utc(last_login_to))
sort_cols = {
"id": User.id,
"created_at": User.created_at,
"last_login_at": User.last_login_at,
}
sort_col = sort_cols.get(sort_by, User.id)
order_fn = asc if sort_order == "asc" else desc
id_order = asc(User.id) if sort_order == "asc" else desc(User.id)
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
def list_onboarding_devices(db: Session, *, limit: int = 500) -> list[dict]: def list_onboarding_devices(db: Session, *, limit: int = 500) -> list[dict]:
@@ -123,7 +175,7 @@ def list_all_withdraw_orders(
quick_filter: str | None = None, quick_filter: str | None = None,
limit: int = 20, limit: int = 20,
cursor: int | None = None, cursor: int | None = None,
) -> tuple[list[WithdrawOrder], int | None]: ) -> tuple[list[WithdrawOrder], int | None, int]:
stmt = select(WithdrawOrder) stmt = select(WithdrawOrder)
needs_user_join = bool(keyword and keyword.strip()) or quick_filter == "high_risk" needs_user_join = bool(keyword and keyword.strip()) or quick_filter == "high_risk"
if needs_user_join: if needs_user_join:
@@ -153,16 +205,16 @@ def list_all_withdraw_orders(
date_col = WithdrawOrder.updated_at if date_field == "updated_at" else WithdrawOrder.created_at date_col = WithdrawOrder.updated_at if date_field == "updated_at" else WithdrawOrder.created_at
if date_from is not None: if date_from is not None:
stmt = stmt.where(date_col >= _as_utc_naive(date_from)) stmt = stmt.where(date_col >= _as_utc(date_from))
if date_to is not None: if date_to is not None:
stmt = stmt.where(date_col <= _as_utc_naive(date_to)) stmt = stmt.where(date_col <= _as_utc(date_to))
now = datetime.now(timezone.utc).replace(tzinfo=None) # tz-aware:列为 timestamptz,比较绝对时刻、与 DB 会话时区无关(同 _as_utc / stats.py)
now = datetime.now(timezone.utc)
today_start = ( today_start = (
datetime.now(ZoneInfo("Asia/Shanghai")) datetime.now(ZoneInfo("Asia/Shanghai"))
.replace(hour=0, minute=0, second=0, microsecond=0) .replace(hour=0, minute=0, second=0, microsecond=0)
.astimezone(timezone.utc) .astimezone(timezone.utc)
.replace(tzinfo=None)
) )
if quick_filter == "abnormal": if quick_filter == "abnormal":
stmt = stmt.where( stmt = stmt.where(
@@ -207,21 +259,19 @@ def list_all_withdraw_orders(
sort_col = sort_cols.get(sort_by, WithdrawOrder.created_at) sort_col = sort_cols.get(sort_by, WithdrawOrder.created_at)
order_fn = asc if sort_order == "asc" else desc order_fn = asc if sort_order == "asc" else desc
id_order = asc(WithdrawOrder.id) if sort_order == "asc" else desc(WithdrawOrder.id) id_order = asc(WithdrawOrder.id) if sort_order == "asc" else desc(WithdrawOrder.id)
stmt = stmt.order_by(order_fn(sort_col), id_order) return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
offset = max(cursor or 0, 0)
rows = list(db.execute(stmt.offset(offset).limit(limit + 1)).scalars().all())
has_more = len(rows) > limit
items = rows[:limit]
next_cursor = offset + limit if has_more else None
return items, next_cursor
def _as_utc_naive(value: datetime) -> datetime: def _as_utc(value: datetime) -> datetime:
"""前端传 ISO 时间;DB 当前按 UTC naive 比较最稳(SQLite/本地开发一致)。""" """前端传 ISO 时间 → 统一成 tz-aware UTC 再比较。
所有时间列均为 `DateTime(timezone=True)`(Postgres timestamptz); tz-aware 绑定参数
比较的是绝对时刻, DB 会话时区无关恒正确曾用 naive UTC,正确性依赖会话 TimeZone=UTC,
生产会话非 UTC 时筛选边界会整体偏移故统一 tz-aware( stats.py / withdraw_summary 一致)
无时区入参按 UTC 解释"""
if value.tzinfo is None: if value.tzinfo is None:
return value return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc).replace(tzinfo=None) return value.astimezone(timezone.utc)
def list_feedbacks( def list_feedbacks(
@@ -229,15 +279,35 @@ def list_feedbacks(
*, *,
status: str | None = None, status: str | None = None,
user_id: int | None = None, user_id: int | None = None,
content: str | None = None,
created_from: datetime | None = None,
created_to: datetime | None = None,
sort_by: str = "id",
sort_order: str = "desc",
limit: int = 20, limit: int = 20,
cursor: int | None = None, cursor: int | None = None,
) -> tuple[list[Feedback], int | None]: ) -> tuple[list[Feedback], int | None, int]:
"""反馈工单列表。支持 状态 / 用户ID / 内容模糊 / 提交时间范围 筛选,按 id·提交时间排序。
**offset 分页**(cursor=offset):任意列排序下游标语义统一( [list_users]),代价是翻页期间
数据变动可能错位一条admin 低频场景可接受返回 (items, next_cursor, total),total 供页码分页
created_at timestamptz,日期入参统一转 tz-aware UTC 比较"""
stmt = select(Feedback) stmt = select(Feedback)
if status: if status:
stmt = stmt.where(Feedback.status == status) stmt = stmt.where(Feedback.status == status)
if user_id is not None: if user_id is not None:
stmt = stmt.where(Feedback.user_id == user_id) stmt = stmt.where(Feedback.user_id == user_id)
return cursor_paginate(db, stmt, Feedback.id, limit=limit, cursor=cursor) if content and content.strip():
stmt = stmt.where(Feedback.content.ilike(f"%{content.strip()}%"))
if created_from is not None:
stmt = stmt.where(Feedback.created_at >= _as_utc(created_from))
if created_to is not None:
stmt = stmt.where(Feedback.created_at <= _as_utc(created_to))
sort_cols = {"id": Feedback.id, "created_at": Feedback.created_at}
sort_col = sort_cols.get(sort_by, Feedback.id)
order_fn = asc if sort_order == "asc" else desc
id_order = asc(Feedback.id) if sort_order == "asc" else desc(Feedback.id)
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
def get_withdraw_by_out_bill_no(db: Session, out_bill_no: str) -> WithdrawOrder | None: def get_withdraw_by_out_bill_no(db: Session, out_bill_no: str) -> WithdrawOrder | None:
@@ -426,14 +496,14 @@ def list_price_reports(
user_id: int | None = None, user_id: int | None = None,
limit: int = 20, limit: int = 20,
cursor: int | None = None, cursor: int | None = None,
) -> tuple[list[PriceReport], int | None]: ) -> tuple[list[PriceReport], int | None, int]:
"""上报更低价列表(admin 全量,可按状态/用户筛)。游标同 feedback:id 倒序。""" """上报更低价列表(admin 全量,可按状态/用户筛)。offset 分页 + total,id 倒序。"""
stmt = select(PriceReport) stmt = select(PriceReport)
if status: if status:
stmt = stmt.where(PriceReport.status == status) stmt = stmt.where(PriceReport.status == status)
if user_id is not None: if user_id is not None:
stmt = stmt.where(PriceReport.user_id == user_id) stmt = stmt.where(PriceReport.user_id == user_id)
return cursor_paginate(db, stmt, PriceReport.id, limit=limit, cursor=cursor) return offset_paginate(db, stmt, (PriceReport.id.desc(),), limit=limit, cursor=cursor)
def price_report_summary(db: Session) -> dict: def price_report_summary(db: Session) -> dict:
+9 -5
View File
@@ -29,16 +29,20 @@ def get_ad_coin_audit(
str | None, Query(description="reward_video / feed;不传=两类都要") str | None, Query(description="reward_video / feed;不传=两类都要")
] = None, ] = None,
limit: Annotated[int, Query(ge=1, le=500)] = 100, limit: Annotated[int, Query(ge=1, le=500)] = 100,
only_mismatch: Annotated[
bool, Query(description="只看不一致(✗)行;统计数仍按全量,不受影响")
] = False,
) -> AdCoinAuditOut: ) -> AdCoinAuditOut:
audit_date = date or cn_today().isoformat() audit_date = date or cn_today().isoformat()
rows = ad_audit.ad_coin_audit( result = ad_audit.ad_coin_audit(
db, date=audit_date, user_id=user_id, scene=scene, limit=limit, db, date=audit_date, user_id=user_id, scene=scene, limit=limit,
only_mismatch=only_mismatch,
) )
items = [AdCoinAuditRow(**r) for r in rows]
return AdCoinAuditOut( return AdCoinAuditOut(
date=audit_date, date=audit_date,
formula=AdCoinFormulaOut(**ad_audit.formula_snapshot()), formula=AdCoinFormulaOut(**ad_audit.formula_snapshot()),
total=len(items), total=result["total"],
mismatch_count=sum(1 for it in items if not it.matched), mismatch_count=result["mismatch_count"],
items=items, truncated=result["truncated"],
items=[AdCoinAuditRow(**r) for r in result["items"]],
) )
+75
View File
@@ -0,0 +1,75 @@
"""admin 广告收益报表:按 用户/日期/广告类型/应用/代码位 聚合 展示条数 / 收益 / 金币。
任意已登录 admin 可看(只读,不涉及资金操作)聚合逻辑在 app/admin/repositories/ad_revenue.py
"""
from __future__ import annotations
from datetime import date as _date
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from app.admin.deps import AdminDb, get_current_admin
from app.admin.repositories import ad_revenue
from app.admin.schemas.ad_revenue import AdRevenueDaily, AdRevenueReportOut, AdRevenueRow
from app.core.rewards import cn_today
router = APIRouter(
prefix="/admin/api/ad-revenue-report",
tags=["admin-ad-revenue-report"],
dependencies=[Depends(get_current_admin)],
)
# 区间最大跨度(天);超出拒绝,避免审计页一次拉过多天(逐日审计 + 大查询)拖垮接口。
_MAX_RANGE_DAYS = 92
def _parse_day(value: str | None, *, field: str, default: _date) -> _date:
if value is None:
return default
try:
return _date.fromisoformat(value)
except ValueError as e:
raise HTTPException(status_code=422, detail=f"{field} 需为 YYYY-MM-DD") from e
@router.get("", response_model=AdRevenueReportOut, summary="广告收益报表(按 日期区间/用户/类型/应用/代码位 聚合)")
def get_ad_revenue_report(
db: AdminDb,
date_from: Annotated[str | None, Query(description="起始日 北京时间 YYYY-MM-DD,默认今天")] = None,
date_to: Annotated[str | None, Query(description="结束日 北京时间 YYYY-MM-DD,闭区间,默认=date_from")] = None,
user_id: Annotated[int | None, Query(description="只看某用户;不传=全部用户")] = None,
ad_type: Annotated[
str | None,
Query(description="reward_video / feed / draw;不传=全部类型"),
] = None,
granularity: Annotated[
str, Query(description="day=按天 / hour=按小时(北京时间);区间>1 天建议用 day")
] = "day",
limit: Annotated[int, Query(ge=1, le=1000)] = 500,
) -> AdRevenueReportOut:
today = cn_today()
d_from = _parse_day(date_from, field="date_from", default=today)
d_to = _parse_day(date_to, field="date_to", default=d_from)
if d_to < d_from:
raise HTTPException(status_code=422, detail="date_to 不能早于 date_from")
if (d_to - d_from).days + 1 > _MAX_RANGE_DAYS:
raise HTTPException(status_code=422, detail=f"区间最长 {_MAX_RANGE_DAYS}")
result = ad_revenue.ad_revenue_report(
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(),
user_id=user_id, ad_type=ad_type, granularity=granularity, limit=limit,
)
return AdRevenueReportOut(
date_from=d_from.isoformat(),
date_to=d_to.isoformat(),
daily=[AdRevenueDaily(**d) for d in result["daily"]],
total=result["total"],
truncated=result["truncated"],
total_impressions=result["total_impressions"],
total_revenue_yuan=result["total_revenue_yuan"],
total_expected_coin=result["total_expected_coin"],
total_actual_coin=result["total_actual_coin"],
mismatch_count=result["mismatch_count"],
items=[AdRevenueRow(**r) for r in result["items"]],
)
+24 -5
View File
@@ -17,6 +17,12 @@ router = APIRouter(
) )
def _active_super_count(db: AdminDb) -> int:
return sum(
1 for a in admin_repo.list_admins(db) if a.role == "super_admin" and a.status == "active"
)
@router.get("", response_model=list[AdminOut], summary="管理员列表") @router.get("", response_model=list[AdminOut], summary="管理员列表")
def list_admins(db: AdminDb) -> list[AdminOut]: def list_admins(db: AdminDb) -> list[AdminOut]:
return [AdminOut.model_validate(a) for a in admin_repo.list_admins(db)] return [AdminOut.model_validate(a) for a in admin_repo.list_admins(db)]
@@ -48,16 +54,29 @@ def update_admin(
if admin_id == admin.id and body.status == "disabled": if admin_id == admin.id and body.status == "disabled":
raise HTTPException(status_code=400, detail="不能禁用自己") raise HTTPException(status_code=400, detail="不能禁用自己")
# 防自锁:降级 / 禁用某个 super_admin 前,确认操作后仍至少剩 1 个 active super_admin,
# 否则会进入「零可用超管」死局——本路由仅 super 可进,只能改库恢复。
demotes_super = (
target.role == "super_admin"
and target.status == "active"
and (
(body.role is not None and body.role != "super_admin")
or body.status == "disabled"
)
)
if demotes_super and _active_super_count(db) <= 1:
raise HTTPException(status_code=400, detail="不能降级/禁用最后一个超级管理员")
changes: dict = {} changes: dict = {}
if body.role is not None: if body.role is not None and body.role != target.role:
changes["role"] = {"before": target.role, "after": body.role}
target.role = body.role target.role = body.role
changes["role"] = body.role if body.status is not None and body.status != target.status:
if body.status is not None: changes["status"] = {"before": target.status, "after": body.status}
target.status = body.status target.status = body.status
changes["status"] = body.status
if body.password is not None: if body.password is not None:
target.password_hash = hash_password(body.password)
changes["password"] = "reset" changes["password"] = "reset"
target.password_hash = hash_password(body.password)
if not changes: if not changes:
raise HTTPException(status_code=400, detail="无任何变更字段") raise HTTPException(status_code=400, detail="无任何变更字段")
db.commit() db.commit()
+4 -2
View File
@@ -26,9 +26,11 @@ def list_audit_logs(
limit: Annotated[int, Query(ge=1, le=100)] = 50, limit: Annotated[int, Query(ge=1, le=100)] = 50,
cursor: Annotated[int | None, Query()] = None, cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[AdminAuditLogOut]: ) -> CursorPage[AdminAuditLogOut]:
items, next_cursor = audit_repo.list_audit_logs( items, next_cursor, total = audit_repo.list_audit_logs(
db, action=action, target_type=target_type, admin_id=admin_id, limit=limit, cursor=cursor, db, action=action, target_type=target_type, admin_id=admin_id, limit=limit, cursor=cursor,
) )
return CursorPage( return CursorPage(
items=[AdminAuditLogOut.model_validate(x) for x in items], next_cursor=next_cursor, items=[AdminAuditLogOut.model_validate(x) for x in items],
next_cursor=next_cursor,
total=total,
) )
+21 -4
View File
@@ -1,6 +1,7 @@
"""admin 反馈工单:列表(读)+ 标记已处理(写,带审计)。""" """admin 反馈工单:列表(读,支持 状态/用户ID/内容/时间 筛选 + 排序)+ 标记已处理(写,带审计)。"""
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
@@ -25,14 +26,30 @@ def list_feedbacks(
db: AdminDb, db: AdminDb,
status: Annotated[str | None, Query()] = None, status: Annotated[str | None, Query()] = None,
user_id: Annotated[int | None, Query()] = None, user_id: Annotated[int | None, Query()] = None,
content: Annotated[str | None, Query(max_length=100)] = None,
created_from: Annotated[datetime | None, Query()] = None,
created_to: Annotated[datetime | None, Query()] = None,
sort_by: Annotated[str, Query(pattern="^(id|created_at)$")] = "id",
sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc",
limit: Annotated[int, Query(ge=1, le=100)] = 20, limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None, cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[FeedbackOut]: ) -> CursorPage[FeedbackOut]:
items, next_cursor = queries.list_feedbacks( items, next_cursor, total = queries.list_feedbacks(
db, status=status, user_id=user_id, limit=limit, cursor=cursor, db,
status=status,
user_id=user_id,
content=content,
created_from=created_from,
created_to=created_to,
sort_by=sort_by,
sort_order=sort_order,
limit=limit,
cursor=cursor,
) )
return CursorPage( return CursorPage(
items=[FeedbackOut.model_validate(f) for f in items], next_cursor=next_cursor, items=[FeedbackOut.model_validate(f) for f in items],
next_cursor=next_cursor,
total=total,
) )
+8 -4
View File
@@ -40,11 +40,13 @@ def list_price_reports(
limit: Annotated[int, Query(ge=1, le=100)] = 20, limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None, cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[PriceReportOut]: ) -> CursorPage[PriceReportOut]:
items, next_cursor = queries.list_price_reports( items, next_cursor, total = queries.list_price_reports(
db, status=status, user_id=user_id, limit=limit, cursor=cursor, db, status=status, user_id=user_id, limit=limit, cursor=cursor,
) )
return CursorPage( return CursorPage(
items=[PriceReportOut.model_validate(r) for r in items], next_cursor=next_cursor, items=[PriceReportOut.model_validate(r) for r in items],
next_cursor=next_cursor,
total=total,
) )
@@ -60,7 +62,9 @@ def approve_price_report(
admin: Annotated[AdminUser, Depends(require_role("operator"))], admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb, db: AdminDb,
) -> OkResponse: ) -> OkResponse:
rep = db.get(PriceReport, report_id) # 行锁(SELECT FOR UPDATE):并发/连点双请求会都读到 pending → 各发一次金币双倍发奖,
# 锁住该行串行化,第二个请求拿锁后看到 approved → 走 400。SQLite 下 FOR UPDATE 为 no-op。
rep = db.get(PriceReport, report_id, with_for_update=True)
if rep is None: if rep is None:
raise HTTPException(status_code=404, detail="上报记录不存在") raise HTTPException(status_code=404, detail="上报记录不存在")
if rep.status != "pending": if rep.status != "pending":
@@ -88,7 +92,7 @@ def reject_price_report(
admin: Annotated[AdminUser, Depends(require_role("operator"))], admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb, db: AdminDb,
) -> OkResponse: ) -> OkResponse:
rep = db.get(PriceReport, report_id) rep = db.get(PriceReport, report_id, with_for_update=True) # 行锁,同 approve(防并发重复审核)
if rep is None: if rep is None:
raise HTTPException(status_code=404, detail="上报记录不存在") raise HTTPException(status_code=404, detail="上报记录不存在")
if rep.status != "pending": if rep.status != "pending":
+76 -34
View File
@@ -1,6 +1,7 @@
"""admin 用户管理:列表 + 360 详情(读)+ 封禁/解封 + 手动调金币(写,带审计)。""" """admin 用户管理:列表 + 360 详情(读)+ 封禁/解封 + 手动调金币(写,带审计)。"""
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
@@ -28,22 +29,32 @@ router = APIRouter(
) )
@router.get("", response_model=CursorPage[AdminUserListItem], summary="用户列表(筛选+分页)") @router.get("", response_model=CursorPage[AdminUserListItem], summary="用户列表(筛选+排序+分页)")
def list_users( def list_users(
db: AdminDb, db: AdminDb,
phone: Annotated[str | None, Query()] = None, phone: Annotated[str | None, Query()] = None,
register_channel: Annotated[str | None, Query()] = None, register_channel: Annotated[str | None, Query()] = None,
status: Annotated[str | None, Query()] = None, status: Annotated[str | None, Query()] = None,
nickname: Annotated[str | None, Query(max_length=100)] = None,
created_from: Annotated[datetime | None, Query()] = None,
created_to: Annotated[datetime | None, Query()] = None,
last_login_from: Annotated[datetime | None, Query()] = None,
last_login_to: Annotated[datetime | None, Query()] = None,
sort_by: Annotated[str, Query(pattern="^(id|created_at|last_login_at)$")] = "id",
sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc",
limit: Annotated[int, Query(ge=1, le=100)] = 20, limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None, cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[AdminUserListItem]: ) -> CursorPage[AdminUserListItem]:
items, next_cursor = queries.list_users( items, next_cursor, total = queries.list_users(
db, phone=phone, register_channel=register_channel, status=status, db, phone=phone, register_channel=register_channel, status=status,
limit=limit, cursor=cursor, nickname=nickname, created_from=created_from, created_to=created_to,
last_login_from=last_login_from, last_login_to=last_login_to,
sort_by=sort_by, sort_order=sort_order, limit=limit, cursor=cursor,
) )
return CursorPage( return CursorPage(
items=[AdminUserListItem.model_validate(u) for u in items], items=[AdminUserListItem.model_validate(u) for u in items],
next_cursor=next_cursor, next_cursor=next_cursor,
total=total,
) )
@@ -101,7 +112,7 @@ def set_user_debug_trace(
return OkResponse() return OkResponse()
@router.post("/{user_id}/coins", response_model=OkResponse, summary="手动增减金币(带审计)") @router.post("/{user_id}/coins", response_model=OkResponse, summary="手动增减/设值金币(带审计)")
def grant_user_coins( def grant_user_coins(
user_id: int, user_id: int,
body: GrantCoinsRequest, body: GrantCoinsRequest,
@@ -109,33 +120,46 @@ def grant_user_coins(
admin: Annotated[AdminUser, Depends(require_role("finance"))], admin: Annotated[AdminUser, Depends(require_role("finance"))],
db: AdminDb, db: AdminDb,
) -> OkResponse: ) -> OkResponse:
if body.amount == 0:
raise HTTPException(status_code=400, detail="amount 不能为 0")
user = user_repo.get_user_by_id(db, user_id) user = user_repo.get_user_by_id(db, user_id)
if user is None: if user is None:
raise HTTPException(status_code=404, detail="用户不存在") raise HTTPException(status_code=404, detail="用户不存在")
# 负数扣减时不允许扣成负余额(运营误操作保护) # set=设为目标值:读当前余额算出要写的差值,仍复用 grant_coins 写一笔流水(沿用原子/审计/扣负保护)
if body.amount < 0: if body.mode == "set":
acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False) if body.amount < 0:
if acc_now.coin_balance + body.amount < 0: raise HTTPException(status_code=400, detail="目标金币值不能为负")
raise HTTPException( # lock=True:锁账户行,防连点/并发各读同一 before 算同一 delta 双写,余额错位
status_code=400, detail=f"扣减后金币为负(当前余额 {acc_now.coin_balance})" before = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True).coin_balance
) delta = body.amount - before
biz_type = "admin_grant" if body.amount > 0 else "admin_deduct" if delta == 0:
raise HTTPException(status_code=400, detail=f"当前金币已为 {body.amount},无需调整")
else:
if body.amount == 0:
raise HTTPException(status_code=400, detail="amount 不能为 0")
delta = body.amount
# 负数扣减时不允许扣成负余额(运营误操作保护);lock=True 防并发扣穿
if delta < 0:
acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True)
if acc_now.coin_balance + delta < 0:
raise HTTPException(
status_code=400, detail=f"扣减后金币为负(当前余额 {acc_now.coin_balance})"
)
biz_type = "admin_grant" if delta > 0 else "admin_deduct"
# grant_coins 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕) # grant_coins 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕)
acc, _ = wallet_repo.grant_coins( acc, _ = wallet_repo.grant_coins(
db, user_id, body.amount, biz_type=biz_type, remark=f"admin:{body.reason}"[:128], db, user_id, delta, biz_type=biz_type, remark=f"admin:{body.reason}"[:128],
) )
detail = {"amount": delta, "balance_after": acc.coin_balance, "reason": body.reason}
if body.mode == "set":
detail.update({"mode": "set", "target": body.amount, "before": before})
write_audit( write_audit(
db, admin, action="user.coins.grant", target_type="user", target_id=user_id, db, admin, action="user.coins.grant", target_type="user", target_id=user_id,
detail={"amount": body.amount, "balance_after": acc.coin_balance, "reason": body.reason}, detail=detail, ip=get_client_ip(request), commit=False,
ip=get_client_ip(request), commit=False,
) )
db.commit() db.commit()
return OkResponse() return OkResponse()
@router.post("/{user_id}/cash", response_model=OkResponse, summary="手动增减现金(带审计)") @router.post("/{user_id}/cash", response_model=OkResponse, summary="手动增减/设值现金(带审计)")
def grant_user_cash( def grant_user_cash(
user_id: int, user_id: int,
body: GrantCashRequest, body: GrantCashRequest,
@@ -143,32 +167,50 @@ def grant_user_cash(
admin: Annotated[AdminUser, Depends(require_role("finance"))], admin: Annotated[AdminUser, Depends(require_role("finance"))],
db: AdminDb, db: AdminDb,
) -> OkResponse: ) -> OkResponse:
"""给指定用户增/减现金(分)。正=发放、负=扣减;主要用于让无现金用户直接测试提现。""" """给指定用户增/减或设值现金(分)。delta:正=发放、负=扣减;set:直接设为目标值。
if body.amount_cents == 0: 主要用于让无现金用户直接测试提现"""
raise HTTPException(status_code=400, detail="amount_cents 不能为 0")
user = user_repo.get_user_by_id(db, user_id) user = user_repo.get_user_by_id(db, user_id)
if user is None: if user is None:
raise HTTPException(status_code=404, detail="用户不存在") raise HTTPException(status_code=404, detail="用户不存在")
# 负数扣减时不允许扣成负余额(运营误操作保护) # set=设为目标值:读当前余额算差值,仍复用 grant_cash 写一笔流水(沿用原子/审计/扣负保护)
if body.amount_cents < 0: if body.mode == "set":
acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False) if body.amount_cents < 0:
if acc_now.cash_balance_cents + body.amount_cents < 0: raise HTTPException(status_code=400, detail="目标现金值不能为负")
# lock=True:锁账户行,防连点/并发各读同一 before 算同一 delta 双写,余额错位
before = wallet_repo.get_or_create_account(
db, user_id, commit=False, lock=True
).cash_balance_cents
delta = body.amount_cents - before
if delta == 0:
raise HTTPException( raise HTTPException(
status_code=400, detail=f"扣减后现金为负(当前余额 {acc_now.cash_balance_cents})" status_code=400, detail=f"当前现金已为 {body.amount_cents},无需调整"
) )
biz_type = "admin_grant" if body.amount_cents > 0 else "admin_deduct" else:
if body.amount_cents == 0:
raise HTTPException(status_code=400, detail="amount_cents 不能为 0")
delta = body.amount_cents
# 负数扣减时不允许扣成负余额(运营误操作保护);lock=True 防并发扣穿
if delta < 0:
acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True)
if acc_now.cash_balance_cents + delta < 0:
raise HTTPException(
status_code=400, detail=f"扣减后现金为负(当前余额 {acc_now.cash_balance_cents} 分)"
)
biz_type = "admin_grant" if delta > 0 else "admin_deduct"
# grant_cash 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕) # grant_cash 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕)
acc, _ = wallet_repo.grant_cash( acc, _ = wallet_repo.grant_cash(
db, user_id, body.amount_cents, biz_type=biz_type, remark=f"admin:{body.reason}"[:128], db, user_id, delta, biz_type=biz_type, remark=f"admin:{body.reason}"[:128],
) )
detail = {
"amount_cents": delta,
"balance_after_cents": acc.cash_balance_cents,
"reason": body.reason,
}
if body.mode == "set":
detail.update({"mode": "set", "target_cents": body.amount_cents, "before_cents": before})
write_audit( write_audit(
db, admin, action="user.cash.grant", target_type="user", target_id=user_id, db, admin, action="user.cash.grant", target_type="user", target_id=user_id,
detail={ detail=detail, ip=get_client_ip(request), commit=False,
"amount_cents": body.amount_cents,
"balance_after_cents": acc.cash_balance_cents,
"reason": body.reason,
},
ip=get_client_ip(request), commit=False,
) )
db.commit() db.commit()
return OkResponse() return OkResponse()
+11 -4
View File
@@ -63,7 +63,7 @@ def list_withdraws(
limit: Annotated[int, Query(ge=1, le=100)] = 20, limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None, cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[WithdrawOrderOut]: ) -> CursorPage[WithdrawOrderOut]:
items, next_cursor = queries.list_all_withdraw_orders( items, next_cursor, total = queries.list_all_withdraw_orders(
db, db,
user_id=user_id, user_id=user_id,
status=status, status=status,
@@ -78,7 +78,9 @@ def list_withdraws(
cursor=cursor, cursor=cursor,
) )
return CursorPage( return CursorPage(
items=[WithdrawOrderOut.model_validate(o) for o in items], next_cursor=next_cursor, items=[WithdrawOrderOut.model_validate(o) for o in items],
next_cursor=next_cursor,
total=total,
) )
@@ -87,7 +89,12 @@ def withdraws_summary(db: AdminDb) -> WithdrawSummaryOut:
return WithdrawSummaryOut(**queries.withdraw_summary(db)) return WithdrawSummaryOut(**queries.withdraw_summary(db))
@router.get("/health-check", response_model=WxpayHealthCheckOut, summary="提现配置健康检查") @router.get(
"/health-check",
response_model=WxpayHealthCheckOut,
summary="提现配置健康检查",
dependencies=[Depends(require_role("finance"))], # 暴露密钥路径/配置,限财务+super
)
def withdraw_health_check() -> WxpayHealthCheckOut: def withdraw_health_check() -> WxpayHealthCheckOut:
private_path = wxpay._resolve_config_path(settings.WXPAY_MCH_PRIVATE_KEY_PATH) # noqa: SLF001 private_path = wxpay._resolve_config_path(settings.WXPAY_MCH_PRIVATE_KEY_PATH) # noqa: SLF001
public_path = wxpay._resolve_config_path(settings.WXPAY_PUBLIC_KEY_PATH) # noqa: SLF001 public_path = wxpay._resolve_config_path(settings.WXPAY_PUBLIC_KEY_PATH) # noqa: SLF001
@@ -160,7 +167,7 @@ def withdraw_detail(out_bill_no: str, db: AdminDb) -> WithdrawDetailOut:
withdraw_success_cents=overview["withdraw_success_cents"], withdraw_success_cents=overview["withdraw_success_cents"],
) )
recent_withdraws, _ = queries.list_all_withdraw_orders( recent_withdraws, _, _ = queries.list_all_withdraw_orders(
db, user_id=order.user_id, limit=5, cursor=None, db, user_id=order.user_id, limit=5, cursor=None,
) )
recent_cash_transactions, _ = queries.list_all_cash_transactions( recent_cash_transactions, _ = queries.list_all_cash_transactions(
+9 -4
View File
@@ -48,10 +48,15 @@ class AdCoinFormulaOut(BaseModel):
class AdCoinAuditOut(BaseModel): class AdCoinAuditOut(BaseModel):
"""审计响应:公式参照 + 命中条数 + 明细。""" """审计响应:公式参照 + 全量统计 + 明细。"""
date: str = Field(..., description="审计日期(北京时间 YYYY-MM-DD)") date: str = Field(..., description="审计日期(北京时间 YYYY-MM-DD)")
formula: AdCoinFormulaOut formula: AdCoinFormulaOut
total: int = Field(..., description="返回的明细条数") total: int = Field(..., description="该筛选下复算总条数(全量,不受 limit/only_mismatch 影响)")
mismatch_count: int = Field(..., description="其中 matched=false 的条数(=0 说明公式全部生效)") mismatch_count: int = Field(
items: list[AdCoinAuditRow] ..., description="全量不一致条数(=0 说明公式全部生效;在截断前统计,可信)"
)
truncated: bool = Field(
..., description="展示集是否被 limit 截断(true=还有未返回的明细,请缩小范围或调大 limit)"
)
items: list[AdCoinAuditRow] = Field(..., description="展示明细;only_mismatch=true 时只含 ✗ 行")
+90
View File
@@ -0,0 +1,90 @@
"""广告收益报表 schemas。
用户 / 日期 / 广告类型 / 应用 / 代码位 聚合的只读报表:展示条数收益()金币来源
字段 snake_case;收益按元(float),金币按整数
"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class AdRevenueImpression(BaseModel):
"""聚合行下钻的单条**展示**明细(每次广告展示一条,展开该组时展示)。"""
id: int = Field(..., description="ad_ecpm_record 主键")
created_at: datetime
ecpm: str = Field(..., description="本次展示 eCPM 原始值(分/千次展示)")
revenue_yuan: float = Field(..., description="本次展示预估收益(元)= eCPM元 ÷ 1000")
adn: str | None = Field(None, description="实际填充 ADN 子渠道(pangle/gdt…)")
slot_id: str | None = Field(None, description="底层 mediation rit(非我们配置的广告位 ID)")
class AdRevenueRecord(BaseModel):
"""聚合行下钻的单条发奖复算明细(与金币审计同源,展开该组时展示)。"""
record_id: int
created_at: datetime
status: str = Field(..., description="granted / capped / ecpm_missing")
ecpm: str | None = Field(None, description="本次采用的 eCPM 原始值(分/千次展示)")
ecpm_factor: float | None = Field(None, description="因子1(eCPM 档);非 granted 为空")
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="复算与实发是否一致")
class AdRevenueDaily(BaseModel):
"""按日期汇总的一天(供前端按天趋势图;全量,不受 limit 影响)。"""
date: str = Field(..., description="北京时间 YYYY-MM-DD")
impressions: int = Field(..., description="当天展示条数合计")
revenue_yuan: float = Field(..., description="当天预估收益合计(元)")
expected_coin: int = Field(..., description="当天应发金币合计")
actual_coin: int = Field(..., description="当天实发金币合计")
class AdRevenueRow(BaseModel):
"""一个聚合组(report_date × user × ad_type × app_env × our_code_id)的汇总。"""
report_date: str = Field(..., description="该组所属日期(北京时间 YYYY-MM-DD)")
user_id: int
ad_type: str = Field(..., description="reward_video(激励视频) / feed(信息流) / draw(历史 Draw 信息流)")
app_env: str | None = Field(None, description="我们的应用:prod(傻瓜比价正式) / test(测试应用);旧数据为空")
our_code_id: str | None = Field(None, description="我们后台配置的代码位 ID(104xxx);旧数据为空")
hour: int | None = Field(None, description="北京时间小时 023(granularity=hour 时有值;按天为 null)")
impressions: int = Field(..., description="展示条数(每条广告展示一条;轮播每条各计一次)")
revenue_yuan: float = Field(..., description="收益(元)= Σ(eCPM元 ÷ 1000);测试应用多为 0")
expected_coin: int = Field(..., description="应发金币(按公式复算,与金币审计同源)")
actual_coin: int = Field(..., description="实发金币(实际入账,按现发奖算法)")
matched: bool = Field(..., description="该组应发==实发(组内任一条不符则 false)")
adns: list[str] = Field(default_factory=list, description="实际填充的底层 ADN 子渠道集合(如 pangle/gdt)")
impression_records: list[AdRevenueImpression] = Field(
default_factory=list,
description="该组逐条展示明细(时间/eCPM/收益/adn);展开下钻用,无发奖也有(只要有展示)",
)
records: list[AdRevenueRecord] = Field(
default_factory=list,
description="该组逐条发奖复算明细(eCPM/因子1/份数/LT/因子2/应发/实发/一致);展开下钻用,纯展示无发奖记录的组为空",
)
class AdRevenueReportOut(BaseModel):
"""报表响应:全量统计 + 按天趋势 + 聚合明细。"""
date_from: str = Field(..., description="报表起始日期(北京时间 YYYY-MM-DD)")
date_to: str = Field(..., description="报表结束日期(北京时间 YYYY-MM-DD,闭区间;单日时与 date_from 相同)")
daily: list[AdRevenueDaily] = Field(..., description="按日期汇总序列(全量,供按天趋势图)")
total: int = Field(..., description="聚合组总数(全量,不受 limit 影响)")
truncated: bool = Field(..., description="明细是否被 limit 截断")
total_impressions: int = Field(..., description="全量展示条数合计")
total_revenue_yuan: float = Field(..., description="全量收益合计(元)")
total_expected_coin: int = Field(..., description="全量应发金币合计")
total_actual_coin: int = Field(..., description="全量实发金币合计")
mismatch_count: int = Field(..., description="应发≠实发的组数(=0 说明全部按公式发放)")
items: list[AdRevenueRow] = Field(..., description="聚合明细(按 用户→类型→代码位 排序)")
+6 -1
View File
@@ -9,10 +9,15 @@ T = TypeVar("T")
class CursorPage(BaseModel, Generic[T]): class CursorPage(BaseModel, Generic[T]):
"""游标分页响应:items + 下一页游标(next_cursor=None 表示末页)。""" """分页响应:items + 下一页游标(next_cursor=None 表示末页)+ 可选 total
next_cursor:offset 分页时即下一页 offset,加载更多;末页为 None
total:符合筛选条件的总条数,页码分页(antd pagination);不需要总数的接口可不传(None)
"""
items: list[T] items: list[T]
next_cursor: int | None = None next_cursor: int | None = None
total: int | None = None
class OkResponse(BaseModel): class OkResponse(BaseModel):
+9 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field, field_validator
class PriceReportOut(BaseModel): class PriceReportOut(BaseModel):
@@ -36,6 +36,14 @@ class PriceReportOut(BaseModel):
class PriceReportRejectRequest(BaseModel): class PriceReportRejectRequest(BaseModel):
reason: str = Field(min_length=1, max_length=256, description="拒绝理由,用户端记录页会看到") reason: str = Field(min_length=1, max_length=256, description="拒绝理由,用户端记录页会看到")
@field_validator("reason")
@classmethod
def _reason_not_blank(cls, v: str) -> str:
# min_length=1 放过纯空白(" "),trim 后再校验非空,避免审计/用户端记录到空理由
if not v.strip():
raise ValueError("拒绝理由不能为空")
return v.strip()
class PriceReportSummary(BaseModel): class PriceReportSummary(BaseModel):
"""审核台顶部各状态计数。""" """审核台顶部各状态计数。"""
+26 -3
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Literal from typing import Literal
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field, field_validator
class AdminUserListItem(BaseModel): class AdminUserListItem(BaseModel):
@@ -37,15 +37,38 @@ class AdminUserOverview(BaseModel):
feedback_total: int feedback_total: int
def _strip_reason(v: str) -> str:
# min_length=1 放过纯空白(" "),trim 后再校验非空,避免审计记到空原因
if not v.strip():
raise ValueError("操作原因不能为空")
return v.strip()
class GrantCoinsRequest(BaseModel): class GrantCoinsRequest(BaseModel):
amount: int = Field(..., description="金币变动:正=增加,负=扣减(不可为 0)") mode: Literal["delta", "set"] = Field(
"delta", description="delta=增减(amount 为变动量) / set=设为(amount 为目标值,须≥0)"
)
amount: int = Field(
...,
description="delta 模式:金币变动(正=增加,负=扣减,不可为 0);set 模式:目标金币值(须≥0)",
)
reason: str = Field(..., min_length=1, max_length=128, description="操作原因(必填,入审计)") reason: str = Field(..., min_length=1, max_length=128, description="操作原因(必填,入审计)")
_v_reason = field_validator("reason")(_strip_reason)
class GrantCashRequest(BaseModel): class GrantCashRequest(BaseModel):
amount_cents: int = Field(..., description="现金变动(分):正=增加,负=扣减(不可为 0)") mode: Literal["delta", "set"] = Field(
"delta", description="delta=增减(amount_cents 为变动量) / set=设为(amount_cents 为目标值,须≥0)"
)
amount_cents: int = Field(
...,
description="delta 模式:现金变动(分,正=增加,负=扣减,不可为 0);set 模式:目标现金值(分,须≥0)",
)
reason: str = Field(..., min_length=1, max_length=128, description="操作原因(必填,入审计)") reason: str = Field(..., min_length=1, max_length=128, description="操作原因(必填,入审计)")
_v_reason = field_validator("reason")(_strip_reason)
class SetUserStatusRequest(BaseModel): class SetUserStatusRequest(BaseModel):
status: Literal["active", "disabled"] = Field( status: Literal["active", "disabled"] = Field(
+110
View File
@@ -0,0 +1,110 @@
"""平台店铺映射内部上报端点(pricebot → app-server)。
pricebot 在淘宝比价拿到 shopId ,把这一行跨平台店铺映射 POST 到这里落库
**不是给客户端的接口**:不走用户 JWT, server 间共享密钥头 `X-Internal-Secret` 校验
(复用 price.py _check_secret, price-observation 同一密钥)
price.py /internal/price-observation 平行:那个落价格事实,这个落店铺身份映射
"""
from __future__ import annotations
import logging
from typing import Annotated
from fastapi import APIRouter, Header
from app.api.deps import DbSession
from app.api.internal.price import _check_secret
from app.repositories import store_mapping as repo
from app.schemas.store_mapping import (
StoreMappingIn,
StoreMappingInvalidateIn,
StoreMappingInvalidateOut,
StoreMappingOut,
)
logger = logging.getLogger("shagua.internal.store")
router = APIRouter(prefix="/internal", tags=["internal"])
@router.get(
"/store-mapping/lookup",
summary="比价前按源平台店名反查各目标平台已沉淀的店铺 id(命中→pricebot 直接 deeplink)",
)
def lookup_store_mapping(
source_platform: str,
name: str,
db: DbSession,
lat: float | None = None,
lng: float | None = None,
x_internal_secret: Annotated[str | None, Header()] = None,
) -> dict:
_check_secret(x_internal_secret)
result = repo.lookup_nearest(db, source_platform, name, lat, lng)
if result:
hits = ", ".join(
f"{t}:row{v['row_id']}"
f"{'(' + str(v['dist_km']) + 'km)' if 'dist_km' in v else ''}"
f"{v.get('deeplink') or '(无deeplink)'}"
for t, v in result.items()
)
logger.info(
"store_mapping lookup source=%s name=%r geo=(%s,%s) → 命中 %s",
source_platform, name, lat, lng, hits,
)
else:
logger.info(
"store_mapping lookup source=%s name=%r geo=(%s,%s) → MISS",
source_platform, name, lat, lng,
)
return result
@router.post(
"/store-mapping",
response_model=StoreMappingOut,
summary="平台店铺映射内部上报(pricebot→app-server,落 store_mapping)",
)
def report_store_mapping(
payload: StoreMappingIn,
db: DbSession,
x_internal_secret: Annotated[str | None, Header()] = None,
) -> StoreMappingOut:
_check_secret(x_internal_secret)
created, row_id = repo.upsert(db, payload)
logger.info(
"store_mapping trace=%s %s row_id=%s source=%s "
"taobao=(%s,%s) jd=(%s,%s) device=%s user=%s",
payload.trace_id, "新建" if created else "合并", row_id, payload.source_platform,
payload.id_taobao, payload.name_taobao, payload.id_jd, payload.name_jd,
payload.source_device_id, payload.source_user_id,
)
return StoreMappingOut(inserted=created, row_id=row_id)
@router.post(
"/store-mapping/invalidate",
response_model=StoreMappingInvalidateOut,
summary="标记某平台 shopId 的缓存 deeplink 失效(pricebot 撞错误页回退时上报,lookup 不再返回)",
)
def invalidate_store_mapping(
payload: StoreMappingInvalidateIn,
db: DbSession,
x_internal_secret: Annotated[str | None, Header()] = None,
) -> StoreMappingInvalidateOut:
_check_secret(x_internal_secret)
if payload.platform == "taobao":
affected = repo.mark_taobao_deeplink_invalid(db, payload.shop_id)
elif payload.platform == "jd":
affected = repo.mark_jd_deeplink_invalid(db, payload.shop_id)
else:
# 当前只接淘宝/京东; 其它平台先 no-op(affected=0), 不报错 — 向后兼容 pricebot 将来扩展。
logger.info("store_mapping invalidate 跳过: platform=%s 暂不支持", payload.platform)
return StoreMappingInvalidateOut(ok=True, affected=0)
logger.info(
"store_mapping invalidate platform=%s shop_id=%s → 标记失效 %d",
payload.platform, payload.shop_id, affected,
)
return StoreMappingInvalidateOut(ok=True, affected=affected)
+35 -1
View File
@@ -32,6 +32,8 @@ from app.schemas.ad import (
FeedRewardIn, FeedRewardIn,
FeedRewardOut, FeedRewardOut,
PangleCallbackOut, PangleCallbackOut,
RewardNoShowIn,
RewardNoShowOut,
TestGrantIn, TestGrantIn,
TestGrantOut, TestGrantOut,
WatchReportIn, WatchReportIn,
@@ -242,10 +244,12 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm
ad_type=payload.ad_type, ecpm_raw=payload.ecpm, ad_type=payload.ad_type, ecpm_raw=payload.ecpm,
ad_session_id=payload.ad_session_id, ad_session_id=payload.ad_session_id,
adn=payload.adn, slot_id=payload.slot_id, adn=payload.adn, slot_id=payload.slot_id,
app_env=payload.app_env, our_code_id=payload.our_code_id,
) )
logger.info( logger.info(
"ad ecpm report user_id=%d type=%s session=%s ecpm=%s adn=%s slot=%s", "ad ecpm report user_id=%d type=%s session=%s ecpm=%s adn=%s slot=%s app=%s code=%s",
user.id, payload.ad_type, payload.ad_session_id, payload.ecpm, payload.adn, payload.slot_id, user.id, payload.ad_type, payload.ad_session_id, payload.ecpm, payload.adn, payload.slot_id,
payload.app_env, payload.our_code_id,
) )
return EcpmReportOut(ok=True) return EcpmReportOut(ok=True)
@@ -359,6 +363,9 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed
ad_session_id=payload.ad_session_id, ad_session_id=payload.ad_session_id,
adn=payload.adn, adn=payload.adn,
slot_id=payload.slot_id, slot_id=payload.slot_id,
app_env=payload.app_env,
our_code_id=payload.our_code_id,
aborted=payload.aborted,
) )
logger.info( logger.info(
"feed ad reward user_id=%d event=%s status=%s units=%d coin=%d", "feed ad reward user_id=%d event=%s status=%s units=%d coin=%d",
@@ -371,3 +378,30 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed
unit_count=rec.unit_count, unit_count=rec.unit_count,
daily_limit=rewards.get_ad_daily_limit(db), daily_limit=rewards.get_ad_daily_limit(db),
) )
@router.post(
"/reward-noshow",
response_model=RewardNoShowOut,
summary="激励视频提前关闭/未发奖留痕",
dependencies=[Depends(rate_limit(120, 60, "ad-reward-noshow"))],
)
def reward_noshow(payload: RewardNoShowIn, user: CurrentUser, db: DbSession) -> RewardNoShowOut:
"""激励视频展示了但用户提前关/跳过、未触发 S2S 发奖时,客户端 best-effort 上报一条留痕,
让广告收益报表能呈现有展示没发金币的原因不发金币;同一 session 已发奖则跳过
"""
rec = crud_ad.record_reward_noshow(
db,
user.id,
ad_session_id=payload.ad_session_id,
ecpm=payload.ecpm,
adn=payload.adn,
slot_id=payload.slot_id,
app_env=payload.app_env,
our_code_id=payload.our_code_id,
)
logger.info(
"ad reward noshow user_id=%d session=%s watched=%ds -> status=%s",
user.id, payload.ad_session_id, payload.watched_seconds, rec.status,
)
return RewardNoShowOut(ok=True, status=rec.status)
+57 -12
View File
@@ -18,7 +18,7 @@ import httpx
from fastapi import APIRouter, HTTPException, Request, status from fastapi import APIRouter, HTTPException, Request, status
from fastapi.concurrency import run_in_threadpool from fastapi.concurrency import run_in_threadpool
from app.api.deps import DbSession from app.api.deps import CurrentUser, DbSession
from app.core.config import settings from app.core.config import settings
from app.core.pricebot_router import pick_pricebot from app.core.pricebot_router import pick_pricebot
from app.db.session import SessionLocal from app.db.session import SessionLocal
@@ -27,6 +27,8 @@ from app.schemas.coupon_state import (
CouponCompletedTodayOut, CouponCompletedTodayOut,
CouponPromptDismissIn, CouponPromptDismissIn,
CouponPromptShouldShowOut, CouponPromptShouldShowOut,
CouponPromptShownIn,
CouponStatsOut,
) )
logger = logging.getLogger("shagua.coupon") logger = logging.getLogger("shagua.coupon")
@@ -66,11 +68,11 @@ def _extract_coupon_results(resp_json: dict) -> list[dict]:
def _mark_engagement_blocking( def _mark_engagement_blocking(
device_id: str, user_id: int | None, engage_type: str device_id: str, package: str, user_id: int | None, engage_type: str
) -> None: ) -> None:
"""独立 session 写 engagement(async 端点经 run_in_threadpool 调,不阻塞事件循环)。""" """独立 session 写 engagement(async 端点经 run_in_threadpool 调,不阻塞事件循环)。"""
with SessionLocal() as db: with SessionLocal() as db:
coupon_repo.mark_engagement(db, device_id, user_id, engage_type) coupon_repo.mark_engagement(db, device_id, package, user_id, engage_type)
def _record_claims_blocking( def _record_claims_blocking(
@@ -111,14 +113,17 @@ async def coupon_step(
device_id = meta.get("device_id") device_id = meta.get("device_id")
user_id = _to_int(meta.get("user_id")) # 登录态才带;判断不靠它,资产留痕用 user_id = _to_int(meta.get("user_id")) # 登录态才带;判断不靠它,资产留痕用
trace_id = meta.get("trace_id") trace_id = meta.get("trace_id")
# 发起领券时前台 App 包名(step body 带 "package")。频控按 App,这条 engagement 要记到
# 对应 App 上。App 内「去领取」发起时 package 可能缺/为空 → 退化为 "" 占位(全局态)。
pkg = meta.get("package") or ""
# 领券任务首帧(step=0)= 用户已发起领券 → 记一条今日 engagement(claim_started), # 领券任务首帧(step=0)= 用户已发起领券 → 记一条今日 engagement(claim_started),
# 今天这台设备不再弹引导窗(对齐前台"点一键领取即 markEngaged")。写库失败绝不能 # 今天**这个 App** 不再弹引导窗(对齐前台"点一键领取即 markEngaged")。写库失败绝不能
# 连累领券主流程,整段吞掉。 # 连累领券主流程,整段吞掉。
if device_id and meta.get("step") == 0: if device_id and meta.get("step") == 0:
try: try:
await run_in_threadpool( await run_in_threadpool(
_mark_engagement_blocking, device_id, user_id, "claim_started" _mark_engagement_blocking, device_id, pkg, user_id, "claim_started"
) )
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
logger.warning("coupon engagement write failed: %s", e) logger.warning("coupon engagement write failed: %s", e)
@@ -188,14 +193,29 @@ async def coupon_step(
return resp_json return resp_json
@router.post("/prompt/shown", summary="领券引导窗弹出即上报(按 App 记 shown)")
def coupon_prompt_shown(payload: CouponPromptShownIn, db: DbSession) -> dict[str, bool]:
"""客户端弹出引导窗那刻调 → 记一条今日 engagement(shown),今天**这个 App** 不再自动弹。
频控主判据(管跨重装):弹出即占用今天这个 App "一次"用户领//无视都算用掉
后续点领取/拒绝再由 step/dismiss type 升级 (device, package, )
"""
coupon_repo.mark_engagement(
db, payload.device_id, payload.package, payload.user_id, "shown"
)
return {"ok": True}
@router.post("/prompt/dismiss", summary="用户拒绝/关闭领券引导窗(记今日已 engage)") @router.post("/prompt/dismiss", summary="用户拒绝/关闭领券引导窗(记今日已 engage)")
def coupon_prompt_dismiss(payload: CouponPromptDismissIn, db: DbSession) -> dict[str, bool]: def coupon_prompt_dismiss(payload: CouponPromptDismissIn, db: DbSession) -> dict[str, bool]:
"""客户端点关闭引导窗时调用 → 记一条今日 engagement(dismissed),今天不再弹。 """客户端点关闭引导窗时调用 → 记一条今日 engagement(dismissed),今天**这个 App** 不再弹。
server 在透传链路里看不到"用户拒绝"(拒绝不发起领券),故必须客户端通知 server 在透传链路里看不到"用户拒绝"(拒绝不发起领券),故必须客户端通知
MVP 不鉴权, device_id 频控按 (device, package, ), App 独立MVP 不鉴权, device_id
""" """
coupon_repo.mark_engagement(db, payload.device_id, payload.user_id, "dismissed") coupon_repo.mark_engagement(
db, payload.device_id, payload.package, payload.user_id, "dismissed"
)
return {"ok": True} return {"ok": True}
@@ -205,12 +225,12 @@ def coupon_prompt_dismiss(payload: CouponPromptDismissIn, db: DbSession) -> dict
summary="切到外卖 App 时是否还应弹领券引导窗", summary="切到外卖 App 时是否还应弹领券引导窗",
) )
def coupon_prompt_should_show( def coupon_prompt_should_show(
device_id: str, db: DbSession device_id: str, db: DbSession, package: str = ""
) -> CouponPromptShouldShowOut: ) -> CouponPromptShouldShowOut:
"""今天这台设备已 engage(领或拒)过 → should_show=false。客户端据此决定弹不弹 """今天这台设备**这个 App** 已 engage(弹/领/拒)过 → should_show=false。各 App 独立:
(纯后台判据,客户端不再做前台 SP 缓存判断)""" 美团弹过不压淘宝/京东客户端切到目标 App 时带 package (客户端不 "" 全局态)"""
return CouponPromptShouldShowOut( return CouponPromptShouldShowOut(
should_show=not coupon_repo.has_engaged_today(db, device_id) should_show=not coupon_repo.has_engaged_today(db, device_id, package)
) )
@@ -236,3 +256,28 @@ def coupon_completed_today(
return CouponCompletedTodayOut( return CouponCompletedTodayOut(
completed=coupon_repo.has_completed_today(db, device_id) completed=coupon_repo.has_completed_today(db, device_id)
) )
@router.post(
"/completed-today/reset",
summary="重置今日已完成(开发设置全重置用,恢复首页「去领取」卡可点)",
)
def coupon_completed_today_reset(
payload: CouponPromptDismissIn, db: DbSession
) -> dict[str, bool]:
"""删这台设备今天的 completion → has_completed_today 变 false,首页「去领取」卡恢复可点。
/prompt/reset 配套:开发设置重置今日领券弹窗状态一键把今日状态全清MVP 不鉴权"""
coupon_repo.reset_today_completion(db, payload.device_id)
return {"ok": True}
@router.get(
"/stats",
response_model=CouponStatsOut,
summary="累计领券数(「我的」页战绩卡「领取优惠券 X 张」)",
)
def coupon_stats(user: CurrentUser, db: DbSession) -> CouponStatsOut:
"""该登录用户累计领到的券数(SUM(claimed_count),口径见 coupon_repo.sum_claimed_count)。
**鉴权(CurrentUser)**区别于同文件不鉴权的 /step 透传与 /prompt 频控(那些按 device_id):
个人战绩按 user_id 聚合,必须有登录态"""
return CouponStatsOut(coupon_count=coupon_repo.sum_claimed_count(db, user.id))
+5 -6
View File
@@ -1,7 +1,7 @@
"""帮助与反馈 endpoint。 """帮助与反馈 endpoint。
路由前缀 `/api/v1/feedback`, Bearer 鉴权(反馈绑到登录用户,便于回访) 路由前缀 `/api/v1/feedback`, Bearer 鉴权(反馈绑到登录用户,便于回访)
POST / 提交反馈(multipart:content / contact 必填,images 可选 4 ) POST / 提交反馈(multipart:content 必填;contact 可选(原型改版后客户端已不再采集);images 可选 6 )
截图复用 [app.core.media] 落盘到 /media/feedback/ 截图复用 [app.core.media] 落盘到 /media/feedback/
""" """
@@ -20,8 +20,8 @@ logger = logging.getLogger("shagua.feedback")
router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"]) router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"])
_MAX_IMAGES = 4 _MAX_IMAGES = 6
_CONTENT_MAX = 2000 _CONTENT_MAX = 200
_CONTACT_MAX = 128 _CONTACT_MAX = 128
@@ -30,7 +30,8 @@ async def submit_feedback(
user: CurrentUser, user: CurrentUser,
db: DbSession, db: DbSession,
content: str = Form(...), content: str = Form(...),
contact: str = Form(...), # 原型改版后客户端不再采集联系方式;保留字段以兼容旧端 + 后续可能复用,默认空串。
contact: str = Form(default=""),
images: list[UploadFile] = File(default=[]), images: list[UploadFile] = File(default=[]),
) -> FeedbackOut: ) -> FeedbackOut:
content = content.strip() content = content.strip()
@@ -39,8 +40,6 @@ async def submit_feedback(
raise HTTPException(status_code=400, detail="反馈内容不能为空") raise HTTPException(status_code=400, detail="反馈内容不能为空")
if len(content) > _CONTENT_MAX: if len(content) > _CONTENT_MAX:
raise HTTPException(status_code=400, detail="反馈内容过长") raise HTTPException(status_code=400, detail="反馈内容过长")
if not contact:
raise HTTPException(status_code=400, detail="联系方式不能为空")
if len(contact) > _CONTACT_MAX: if len(contact) > _CONTACT_MAX:
raise HTTPException(status_code=400, detail="联系方式过长") raise HTTPException(status_code=400, detail="联系方式过长")
+29 -1
View File
@@ -9,11 +9,16 @@ from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Literal
from pydantic import Field from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent _PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
# 生产环境 JWT secret 的最小可接受长度(字节)。HS256 推荐高熵随机串;<16 视为弱密钥。
_MIN_PROD_SECRET_LEN = 16
# 已知的占位默认值(代码里写死的 default),prod 下绝不能沿用。
_INSECURE_SECRET_DEFAULTS = frozenset({"change-me", "change-me-admin", ""})
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
@@ -198,6 +203,29 @@ class Settings(BaseSettings):
def is_prod(self) -> bool: def is_prod(self) -> bool:
return self.APP_ENV == "prod" return self.APP_ENV == "prod"
@model_validator(mode="after")
def _enforce_prod_secrets(self) -> "Settings":
"""prod 下强校验 JWT secret,弱/默认/空即启动报错(fail-fast,挡住 token 被伪造)。
只校验两个签发凭证:App 用户的 JWT_SECRET_KEY后台的 ADMIN_JWT_SECRET它们沿用默认值
时任何人都能伪造 access/admin token 账号与后台失陷INTERNAL_API_SECRET 默认空 = 内部端点
关闭( 503),是安全的默认态,故不在此强制dev 不触发,便于本地直接起
"""
if not self.is_prod:
return self
weak: list[str] = []
for name in ("JWT_SECRET_KEY", "ADMIN_JWT_SECRET"):
value = getattr(self, name)
if value in _INSECURE_SECRET_DEFAULTS or len(value) < _MIN_PROD_SECRET_LEN:
weak.append(name)
if weak:
raise ValueError(
f"APP_ENV=prod 但检测到弱/默认密钥: {', '.join(weak)} —— 必须改成 "
f"{_MIN_PROD_SECRET_LEN} 位高熵随机串(否则 JWT 可被伪造 → 用户/后台账号失陷)。"
f"生成示例: python -c \"import secrets; print(secrets.token_urlsafe(48))\""
)
return self
@lru_cache(maxsize=1) @lru_cache(maxsize=1)
def get_settings() -> Settings: def get_settings() -> Settings:
+18 -7
View File
@@ -143,6 +143,13 @@ AD_LT_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = (
(1.0, 11, None), (1.0, 11, None),
) )
# 客户端可影响的 eCPM 可信上限(分/千次展示):信息流广告一期由客户端上报 eCPM,伪造天价 eCPM
# 可铸出天量金币(见 calculate_ad_reward_coin)。真实 eCPM 一般 <¥100 CPM(=10000 分),档位表顶档
# 为 >¥400(=40000 分);取 ¥500 CPM=50000 分,留足真实头部余量又封死伪造值。钳在唯一计算口
# calculate_ad_reward_coin,故 feed 与 reward_video(回退客户端上报 eCPM 时)一并护住;阈值设在所有
# 真实值之上,不会少发正规奖励。
AD_ECPM_MAX_FEN: int = 50_000
def parse_ecpm_fen(ecpm: str | int | float | None) -> float: def parse_ecpm_fen(ecpm: str | int | float | None) -> float:
"""解析 eCPM 原始值(穿山甲 getEcpm 原值,单位=分/千次展示)。非法/缺失→0。""" """解析 eCPM 原始值(穿山甲 getEcpm 原值,单位=分/千次展示)。非法/缺失→0。"""
@@ -172,24 +179,28 @@ def ad_ecpm_factor(ecpm_yuan: float) -> float:
return 0.1 return 0.1
def ad_lt_factor(today_count_after_this: int) -> float: def ad_lt_factor(count_after_this: int) -> float:
"""LT 因子。today_count_after_this 是当天累计第 N 条/份广告奖励""" """LT 因子。count_after_this 是该账号累计第 N 条/份看视频奖励(不按天重置)"""
count = max(1, today_count_after_this) count = max(1, count_after_this)
for factor, lo, hi in AD_LT_FACTOR_TABLE: for factor, lo, hi in AD_LT_FACTOR_TABLE:
if count >= lo and (hi is None or count <= hi): if count >= lo and (hi is None or count <= hi):
return factor return factor
return 1.0 return 1.0
def calculate_ad_reward_coin(ecpm: str | int | float | None, today_count_after_this: int) -> int: def calculate_ad_reward_coin(ecpm: str | int | float | None, count_after_this: int) -> int:
"""按金币数值体系计算单份广告奖励金币。 """按金币数值体系计算单份广告奖励金币。
eCPM 是穿山甲 getEcpm 原值,单位/千次展示; ÷100 转成元(因子判档 + 收益换算都用元) eCPM 是穿山甲 getEcpm 原值,单位/千次展示; ÷100 转成元(因子判档 + 收益换算都用元)
单次收益()= eCPM元 ÷ 1000(每千次单次) × 因子1(eCPM 元档) × 因子2(LT); 单次收益()= eCPM元 ÷ 1000(每千次单次) × 因子1(eCPM 元档) × 因子2(LT);
再按 1 =10000 金币取整 再按 1 =10000 金币取整count_after_this 为账号累计第 N 次看视频(LT 因子用,不按天重置)
eCPM 在此先钳到 AD_ECPM_MAX_FEN(¥500 CPM):信息流广告一期 eCPM 由客户端上报,伪造天价值
会铸天量金币;钳在这唯一入口,feed reward_video 回退客户端 eCPM 的路径都护住,且阈值高于
所有真实值,不影响正规发奖
""" """
ecpm_yuan = parse_ecpm_yuan(ecpm) ecpm_yuan = min(parse_ecpm_yuan(ecpm), AD_ECPM_MAX_FEN / 100.0)
yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(today_count_after_this) yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(count_after_this)
return max(0, round(yuan * COIN_PER_YUAN)) return max(0, round(yuan * COIN_PER_YUAN))
+3 -1
View File
@@ -21,6 +21,7 @@ from app.api.v1.compare_milestone import router as compare_milestone_router
from app.api.v1.compare_record import router as compare_record_router from app.api.v1.compare_record import router as compare_record_router
from app.api.v1.coupon import router as coupon_router from app.api.v1.coupon import router as coupon_router
from app.api.internal.price import router as internal_price_router from app.api.internal.price import router as internal_price_router
from app.api.internal.store import router as internal_store_router
from app.api.v1.feedback import router as feedback_router from app.api.v1.feedback import router as feedback_router
from app.api.v1.invite import router as invite_router from app.api.v1.invite import router as invite_router
from app.api.v1.meituan import router as meituan_router from app.api.v1.meituan import router as meituan_router
@@ -102,8 +103,9 @@ app.include_router(savings_router)
app.include_router(ad_router) app.include_router(ad_router)
app.include_router(order_router) app.include_router(order_router)
app.include_router(report_router) app.include_router(report_router)
# 内部(server→server)端点:pricebot 上报价格观测,靠共享密钥头校验,不对客户端开放。 # 内部(server→server)端点:pricebot 上报价格观测 / 店铺映射,靠共享密钥头校验,不对客户端开放。
app.include_router(internal_price_router) app.include_router(internal_price_router)
app.include_router(internal_store_router)
app.include_router(platform_router) app.include_router(platform_router)
# 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。 # 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。
+1
View File
@@ -23,6 +23,7 @@ from app.models.price_observation import PriceObservation # noqa: F401
from app.models.price_report import PriceReport # noqa: F401 from app.models.price_report import PriceReport # noqa: F401
from app.models.savings import SavingsRecord # noqa: F401 from app.models.savings import SavingsRecord # noqa: F401
from app.models.signin import SigninBoostRecord, SigninRecord # noqa: F401 from app.models.signin import SigninBoostRecord, SigninRecord # noqa: F401
from app.models.store_mapping import StoreMapping # noqa: F401
from app.models.task import UserTask # noqa: F401 from app.models.task import UserTask # noqa: F401
from app.models.user import User # noqa: F401 from app.models.user import User # noqa: F401
from app.models.wallet import ( # noqa: F401 from app.models.wallet import ( # noqa: F401
+5
View File
@@ -35,6 +35,11 @@ class AdEcpmRecord(Base):
adn: Mapped[str | None] = mapped_column(String(32), nullable=True) adn: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 实际展示用的代码位(底层 mediation rit,非客户端配置位) # 实际展示用的代码位(底层 mediation rit,非客户端配置位)
slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True) slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 我们的穿山甲应用环境:prod(傻瓜比价正式应用) / test(测试应用)。客户端按 AdConfig.useProductionApp 上报。
# 与底层 adn 不同:这是「我们用的是哪个 App」,adn 是「聚合后实际填充的子渠道」。旧数据为 NULL。
app_env: Mapped[str | None] = mapped_column(String(16), nullable=True)
# 我们在穿山甲后台配置的代码位 ID(AdConfig.feedCodeId/rewardCodeId 返回的 104xxx,**非** slot_id 的底层 rit)。旧数据为 NULL。
our_code_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 客户端上报的 eCPM 原始字符串(单位:分/千次展示,SDK getEcpm 原值,原样存) # 客户端上报的 eCPM 原始字符串(单位:分/千次展示,SDK getEcpm 原值,原样存)
ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False) ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False)
# 北京时间日期串 'YYYY-MM-DD',按它等值做"按天聚合"(不在 SQL 里做跨时区 date 比较) # 北京时间日期串 'YYYY-MM-DD',按它等值做"按天聚合"(不在 SQL 里做跨时区 date 比较)
+3
View File
@@ -30,6 +30,9 @@ class AdFeedRewardRecord(Base):
ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False) ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False)
adn: Mapped[str | None] = mapped_column(String(32), nullable=True) adn: Mapped[str | None] = mapped_column(String(32), nullable=True)
slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True) slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 来源(广告收益报表用):我们的应用环境 prod/test + 我们配置的代码位 104xxx,由客户端 feed-reward 上报带上。旧数据为 NULL。
app_env: Mapped[str | None] = mapped_column(String(16), nullable=True)
our_code_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0) coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="granted") status: Mapped[str] = mapped_column(String(16), nullable=False, default="granted")
+4
View File
@@ -33,6 +33,10 @@ class AdRewardRecord(Base):
ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
# 本次发奖采用的 eCPM 原始值(回调自带或按 ad_session_id 匹配的客户端上报) # 本次发奖采用的 eCPM 原始值(回调自带或按 ad_session_id 匹配的客户端上报)
ecpm_raw: Mapped[str | None] = mapped_column(String(32), nullable=True) ecpm_raw: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 来源(广告收益报表用):我们的应用环境 prod/test + 我们配置的代码位 104xxx。
# S2S 回调本身不带这俩,发奖时按 ad_session_id 匹配 ad_ecpm_record 回填(查不到为 NULL)。
app_env: Mapped[str | None] = mapped_column(String(16), nullable=True)
our_code_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 北京时间日期串 'YYYY-MM-DD',按它等值统计当日发奖次数 # 北京时间日期串 'YYYY-MM-DD',按它等值统计当日发奖次数
reward_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False) reward_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False)
# 穿山甲上报的奖励名(参考,不作发奖依据) # 穿山甲上报的奖励名(参考,不作发奖依据)
+1 -1
View File
@@ -94,7 +94,7 @@ class ComparisonRecord(Base):
# ===== 明细(JSON,越详细越好)===== # ===== 明细(JSON,越详细越好)=====
# 下单菜品 [{name, qty, specs?}] # 下单菜品 [{name, qty, specs?}]
items: Mapped[list] = mapped_column(_JSON, nullable=False, default=list) 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) comparison_results: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
# 目标平台未找到、跳过的菜名 # 目标平台未找到、跳过的菜名
skipped_dish_names: Mapped[list] = mapped_column(_JSON, nullable=False, default=list) skipped_dish_names: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
+16 -6
View File
@@ -137,25 +137,35 @@ class CouponDailyCompletion(Base):
class CouponPromptEngagement(Base): class CouponPromptEngagement(Base):
"""按 (device, 自然日) 记"今天是否对领券引导窗表达过意向"——弹窗频控源。""" """按 (device, **App**, 自然日) 记"今天这个 App 是否对领券引导窗表达过意向"——弹窗频控源。
2026-06-14:频控维度从 (device, ) 改为 (device, package, )需求是美团/淘宝/京东
各自独立在美团弹过/领过,不影响淘宝京东今天仍各弹一次原来缺 package 任一 App
弹过就把整台设备当天标记 engage,其余 App 被压住不弹(bug)
"""
__tablename__ = "coupon_prompt_engagement" __tablename__ = "coupon_prompt_engagement"
__table_args__ = ( __table_args__ = (
# 一台设备一天一条:今天 engage 过(领或拒)不再弹。 # 一台设备、一个 App、一天一条:今天**这个 App** engage 过(领或拒)不再弹该 App
UniqueConstraint( UniqueConstraint(
"device_id", "engage_date", "device_id", "package", "engage_date",
name="uq_coupon_engage_device_date", name="uq_coupon_engage_device_pkg_date",
), ),
) )
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
device_id: Mapped[str] = mapped_column(String(64), nullable=False) device_id: Mapped[str] = mapped_column(String(64), nullable=False)
# 触发弹窗的目标 App 包名(com.sankuai.meituan / com.taobao.taobao / com.jingdong.app.mall)。
# 频控维度,各 App 独立。旧行(改造前)无此值 → 迁移用占位 "" 填,不影响新逻辑判断。
package: Mapped[str] = mapped_column(
String(64), nullable=False, server_default=""
)
user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True) user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
# Asia/Shanghai 自然日。 # Asia/Shanghai 自然日。
engage_date: Mapped[date] = mapped_column(Date, nullable=False) engage_date: Mapped[date] = mapped_column(Date, nullable=False)
# claim_started(点了一键领取)/ dismissed(点了拒绝/关闭)。仅记录区分, # claim_started(点了一键领取)/ dismissed(点了拒绝/关闭)/ shown(自动弹出即记)。
# 判断只看"今天有没有这条",type 不影响弹不弹。 # 仅记录区分,判断只看"今天这个 App 有没有这条",type 不影响弹不弹。
engage_type: Mapped[str] = mapped_column(String(16), nullable=False) engage_type: Mapped[str] = mapped_column(String(16), nullable=False)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
+3 -2
View File
@@ -1,7 +1,8 @@
"""用户反馈表(帮助与反馈)。 """用户反馈表(帮助与反馈)。
每条 = 用户一次提交content 必填,contact 必填(微信/QQ/手机,便于回访),images 为可选的 每条 = 用户一次提交content 必填;contact 原为必填(微信/QQ/手机),原型改版后客户端不再采集,
截图 URL 列表(/media/feedback/...,JSON )status: new(待处理)/ handled(已处理) 新数据存空串(列保持 NOT NULL,免迁移;历史数据仍有值);images 为可选的截图 URL 列表
(/media/feedback/...,JSON )status: new(待处理)/ handled(已处理)
""" """
from __future__ import annotations from __future__ import annotations
+122
View File
@@ -0,0 +1,122 @@
"""平台店铺表(store_mapping)—— 跨平台"同一家店"的 id/名 映射资产层。
每完成一次淘宝比价(在目标淘宝店通过 更多操作分享复制链接 拿到分享短链
HTTP 解析出 shopId ),pricebot serverserver 内部上报落这里一行**与登录无关
不依赖客户端鉴权**(比价透传链路当前不鉴权,user_id 客户端带上时一并记)
price_observation 的区别:
- price_observation:平台/门店视角的**价格事实**(某店这单多少钱)
- store_mapping:平台/门店视角的**身份映射**(同一家物理店在 淘宝/美团/京东 各自的
店铺 id 与店名)是未来"我见过这家店→跳过重新搜索/匹配"的源头两表独立
先存下来用法后说:列尽量铺全(各平台 id/ + 地理 + 溯源 + 淘宝/美团原料 URL),
attrs(JSONB)兜底存灵活明细,免得每多记一个字段就迁移 schema
数据质量:跨平台"同一家店"的连接来自 agent LLM 店铺匹配,匹配错则一行里连错店
本表是 append-only 原始记录(每比价一行trace_id 幂等防重试重复),清洗/归一二期再做
已接通**淘宝**(id_taobao=shopId)**美团**(meituan_poi_id_str,非稳定主键单列存)
**京东**(id_jd=storeId + jd_vender_id,均稳定数字主键;3.cn 短链反查)
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import (
JSON,
DateTime,
Float,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
# PG 上用 JSONB,SQLite(本地/测试)退化为通用 JSON(同 price_observation / comparison_record)。
_JSON = JSON().with_variant(JSONB(), "postgresql")
class StoreMapping(Base):
__tablename__ = "store_mapping"
__table_args__ = (
# 一次比价(trace)只记一条:pricebot 重试 / 客户端 replay 重复上报时幂等去重。
# 一次淘宝比价 = 一个目标淘宝店 → 一行映射(源 + 各平台身份压在同一行)。
UniqueConstraint("trace_id", name="uq_store_mapping_trace"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# ===== 跨平台店铺身份(同一家物理店在各平台的 id/名;按比价角色稀疏填充)=====
# id_taobao = 分享短链解析出的 shopId(淘宝当目标、走完取 id 流程才有);
# name_taobao = 店铺页 a11y content_desc "店铺标题:xxx" 剥前缀。
id_taobao: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
name_taobao: Mapped[str | None] = mapped_column(String(128), nullable=True)
# 美团:无同款 share→稳定id 机制(poi_id_str 每次变,见下方 meituan_poi_id_str),id_meituan
# 留给将来 CPS API 的稳定 poi_id;name-only 时仅 name_meituan(源平台店名来自 intent/agent 匹配名)。
id_meituan: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
name_meituan: Mapped[str | None] = mapped_column(String(128), nullable=True)
# 京东:已接通 share→id(3.cn 短链反查)。id_jd = storeId(门店稳定数字主键,同 taobao shopId→
# id_taobao);venderId(deeplink 还需)单列存 jd_vender_id。name_jd = 店铺页店名。
id_jd: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
name_jd: Mapped[str | None] = mapped_column(String(128), nullable=True)
# ===== 地理(同名店异地区分 / 地理分桶匹配的主要燃料)=====
city: Mapped[str | None] = mapped_column(String(64), nullable=True)
geohash: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True)
lng: Mapped[float | None] = mapped_column(Float, nullable=True)
lat: Mapped[float | None] = mapped_column(Float, nullable=True)
# 淘宝门店地址(店铺页 a11y 抓到才有;比经纬度更利于人工/LLM 匹配)
taobao_address: Mapped[str | None] = mapped_column(String(256), nullable=True)
# ===== 溯源 / 用户画像 =====
# 源平台(发起比价那家:meituan / taobao_flash / jd_waimai ...)
source_platform: Mapped[str | None] = mapped_column(String(32), index=True, nullable=True)
business_type: Mapped[str] = mapped_column(String(16), nullable=False, default="food")
# pricebot 侧 trace_id:回指原始 trace(溯源)+ 幂等去重键(uq_store_mapping_trace)
trace_id: Mapped[str] = mapped_column(String(64), nullable=False)
source_device_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
# user_id 当前比价链路不鉴权拿不到,客户端带上时才有;先可空。
source_user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
# ===== 淘宝原料(可复跳 / 可重解析 / 调试;URL 可能很长 → Text)=====
taobao_share_url: Mapped[str | None] = mapped_column(String(256), nullable=True) # m.tb.cn 短链
taobao_resolved_url: Mapped[str | None] = mapped_column(Text, nullable=True) # 解析出的目标 URL(含 shopId)
taobao_deeplink: Mapped[str | None] = mapped_column(Text, nullable=True) # 拼好的 et-store/search deeplink
# 淘宝 deeplink 失效标记:比价撞"页面出错了"降级页时被置(pricebot server→server invalidate),
# NULL=有效。lookup 反查过滤掉非 NULL 的淘宝候选,不再返回坏 deeplink(重搜会写新行覆盖)。
taobao_deeplink_invalid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# ===== 美团原料(同淘宝;dpurl.cn 短链 → 302 反查 poi_id_str → imeituan:// deeplink)=====
# ⚠️ poi_id_str 每次分享重新加密、非稳定主键(调研文档 §八), 故单列存"可复跳的一次性票据",
# 不进 id_meituan —— 后者留给将来 CPS API 拿到的稳定数字 poi_id。
meituan_poi_id_str: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
meituan_share_url: Mapped[str | None] = mapped_column(String(256), nullable=True) # dpurl.cn 短链
meituan_resolved_url: Mapped[str | None] = mapped_column(Text, nullable=True) # 302 落地 menu URL(含 poi_id_str)
meituan_deeplink: Mapped[str | None] = mapped_column(Text, nullable=True) # 拼好的 imeituan:// 店内搜索 deeplink
# ===== 京东原料(秒送;3.cn 短链 → 跟随重定向反查 venderId+storeId → openapp.jdmobile:// deeplink)=====
# storeId 进 id_jd(稳定店主键);venderId 单列存(deeplink 模板 venderId+storeId 都要,且 venderId
# 是商家维度、可跨门店,与门店 storeId 分开记)。其余三列与 taobao_*/meituan_* 平行。
jd_vender_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
jd_share_url: Mapped[str | None] = mapped_column(String(256), nullable=True) # 3.cn 短链
jd_resolved_url: Mapped[str | None] = mapped_column(Text, nullable=True) # 反查出的目标 openapp.jdmobile:// deeplink
jd_deeplink: Mapped[str | None] = mapped_column(Text, nullable=True) # 拼好的 pages/search 店内搜索 deeplink
# 京东 deeplink 失效标记(同 taobao_deeplink_invalid_at):撞"当前门店超出配送范围"页时被置,NULL=有效。
jd_deeplink_invalid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# 灵活字段兜底(免得加字段就迁移)
attrs: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return (
f"<StoreMapping id={self.id} taobao=({self.id_taobao!r},{self.name_taobao!r}) "
f"source={self.source_platform} trace_id={self.trace_id}>"
)
+9 -1
View File
@@ -23,8 +23,14 @@ def create_ecpm_record(
ad_session_id: str | None = None, ad_session_id: str | None = None,
adn: str | None = None, adn: str | None = None,
slot_id: str | None = None, slot_id: str | None = None,
app_env: str | None = None,
our_code_id: str | None = None,
) -> AdEcpmRecord: ) -> AdEcpmRecord:
"""落一条 eCPM 上报记录。report_date 用北京时间当天,供按天聚合。""" """落一条 eCPM 上报记录。report_date 用北京时间当天,供按天聚合。
app_env(prod/test) our_code_id(我们后台配置的 104xxx 代码位)供广告收益报表按
应用/代码位聚合; adn(实际填充子渠道)/slot_id(底层 rit)是两组不同口径
"""
if ad_session_id: if ad_session_id:
existing = find_by_session(db, user_id=user_id, ad_session_id=ad_session_id) existing = find_by_session(db, user_id=user_id, ad_session_id=ad_session_id)
if existing is not None: if existing is not None:
@@ -35,6 +41,8 @@ def create_ecpm_record(
ad_session_id=ad_session_id, ad_session_id=ad_session_id,
adn=adn, adn=adn,
slot_id=slot_id, slot_id=slot_id,
app_env=app_env,
our_code_id=our_code_id,
ecpm_raw=ecpm_raw, ecpm_raw=ecpm_raw,
report_date=cn_today().isoformat(), report_date=cn_today().isoformat(),
) )
+64 -6
View File
@@ -16,6 +16,10 @@ from app.repositories import wallet as crud_wallet
FEED_REWARD_UNIT_SECONDS = 10 FEED_REWARD_UNIT_SECONDS = 10
# 单个 feed 事件的时长上限(秒):一期 duration_seconds 由客户端上报,伪造超长时长会刷份数
# (每 10 秒 1 份)。真实单条信息流视频远小于此;取 120s=12 份封顶,挡刷量、不影响正规单。
# 与 rewards.AD_ECPM_MAX_FEN(eCPM 钳顶)合起来,把单事件可铸金币锁进有限区间。
FEED_MAX_DURATION_SECONDS = 120
def _find_by_event(db: Session, client_event_id: str) -> AdFeedRewardRecord | None: def _find_by_event(db: Session, client_event_id: str) -> AdFeedRewardRecord | None:
@@ -38,15 +42,14 @@ def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
).scalar_one() ).scalar_one()
def _unit_reward_total(db: Session, user_id: int, ecpm: str, unit_count: int, today: str) -> int: def _unit_reward_total(db: Session, user_id: int, ecpm: str, unit_count: int) -> int:
"""按每个 10 秒单位逐份计算奖励,LT 使用当天累计奖励份序号""" """按每个 10 秒单位逐份计算奖励,LT 使用**账号累计**奖励份序号(不按天重置)"""
if unit_count <= 0: if unit_count <= 0:
return 0 return 0
existing_units = db.execute( existing_units = db.execute(
select(func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0)) select(func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0))
.where( .where(
AdFeedRewardRecord.user_id == user_id, AdFeedRewardRecord.user_id == user_id,
AdFeedRewardRecord.reward_date == today,
AdFeedRewardRecord.status == "granted", AdFeedRewardRecord.status == "granted",
) )
).scalar_one() ).scalar_one()
@@ -66,16 +69,48 @@ def grant_feed_reward(
ad_session_id: str | None = None, ad_session_id: str | None = None,
adn: str | None = None, adn: str | None = None,
slot_id: str | None = None, slot_id: str | None = None,
app_env: str | None = None,
our_code_id: str | None = None,
aborted: bool = False,
) -> AdFeedRewardRecord: ) -> AdFeedRewardRecord:
"""完成一条信息流广告后结算奖励。client_event_id 幂等,同号重试不重复发。""" """比价/领券一整场信息流(轮播多条)结束后结算奖励。client_event_id 幂等,同号重试不重复发。
发奖规则:**比价全程不关广告才发**,金额按整场**总观看时长**折份( 10 1 )
- aborted=True(用户中途 关闭):整场不发, status='closed_early' 留痕(原因可查)
- 总时长不足 10 (unit_count==0): status='too_short' 不发
- 命中当日条数上限: status='capped' 不发
duration_seconds 是整场累计秒数一期 eCPM/时长均由客户端上报,故服务端两道硬闸防刷:时长钳到
FEED_MAX_DURATION_SECONDS 限单场份数,eCPM rewards.calculate_ad_reward_coin 内钳到
AD_ECPM_MAX_FEN 限单份金额;叠加每日 get_ad_daily_limit 条数上限,把单用户日产出锁进有限区间
"""
existing = _find_by_event(db, client_event_id) existing = _find_by_event(db, client_event_id)
if existing is not None: if existing is not None:
return existing return existing
today = cn_today().isoformat() today = cn_today().isoformat()
safe_duration = max(0, min(duration_seconds, 24 * 60 * 60)) # 客户端上报时长先钳到 FEED_MAX_DURATION_SECONDS,防伪造超长时长刷份数(见常量注释)。
safe_duration = max(0, min(duration_seconds, FEED_MAX_DURATION_SECONDS))
unit_count = safe_duration // FEED_REWARD_UNIT_SECONDS unit_count = safe_duration // FEED_REWARD_UNIT_SECONDS
# 用户中途关闭广告:整场不发(全程不关才发),留一条 closed_early 记录原因。优先级最高。
if aborted:
rec = AdFeedRewardRecord(
client_event_id=client_event_id,
user_id=user_id,
reward_date=today,
duration_seconds=safe_duration,
unit_count=unit_count,
ad_session_id=ad_session_id,
ecpm_raw=ecpm,
adn=adn,
slot_id=slot_id,
app_env=app_env,
our_code_id=our_code_id,
coin=0,
status="closed_early",
)
return _commit_record(db, rec, client_event_id)
if _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db): if _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db):
rec = AdFeedRewardRecord( rec = AdFeedRewardRecord(
client_event_id=client_event_id, client_event_id=client_event_id,
@@ -87,12 +122,33 @@ def grant_feed_reward(
ecpm_raw=ecpm, ecpm_raw=ecpm,
adn=adn, adn=adn,
slot_id=slot_id, slot_id=slot_id,
app_env=app_env,
our_code_id=our_code_id,
coin=0, coin=0,
status="capped", status="capped",
) )
return _commit_record(db, rec, client_event_id) return _commit_record(db, rec, client_event_id)
coin = _unit_reward_total(db, user_id, ecpm, unit_count, today) # 整场总时长不足 10 秒,凑不满一份 → 不发,记 too_short 留痕。
if unit_count == 0:
rec = AdFeedRewardRecord(
client_event_id=client_event_id,
user_id=user_id,
reward_date=today,
duration_seconds=safe_duration,
unit_count=0,
ad_session_id=ad_session_id,
ecpm_raw=ecpm,
adn=adn,
slot_id=slot_id,
app_env=app_env,
our_code_id=our_code_id,
coin=0,
status="too_short",
)
return _commit_record(db, rec, client_event_id)
coin = _unit_reward_total(db, user_id, ecpm, unit_count)
if coin > 0: if coin > 0:
crud_wallet.grant_coins( crud_wallet.grant_coins(
db, user_id, coin, db, user_id, coin,
@@ -109,6 +165,8 @@ def grant_feed_reward(
ecpm_raw=ecpm, ecpm_raw=ecpm,
adn=adn, adn=adn,
slot_id=slot_id, slot_id=slot_id,
app_env=app_env,
our_code_id=our_code_id,
coin=coin, coin=coin,
status="granted", status="granted",
) )
+79 -5
View File
@@ -54,6 +54,20 @@ def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
).scalar_one() ).scalar_one()
def _granted_cumulative(db: Session, user_id: int) -> int:
"""账号累计已发奖的看视频次数(不按天重置)——LT 因子(因子2)用它定"第 N 次广告"
_granted_today 区别仅在去掉 reward_date 过滤;每日次数上限/冷却仍按当日统计"""
return db.execute(
select(func.count())
.select_from(AdRewardRecord)
.where(
AdRewardRecord.user_id == user_id,
AdRewardRecord.status == "granted",
AdRewardRecord.reward_scene == "reward_video",
)
).scalar_one()
def grant_ad_reward( def grant_ad_reward(
db: Session, db: Session,
user_id: int, user_id: int,
@@ -76,6 +90,16 @@ def grant_ad_reward(
today = cn_today().isoformat() today = cn_today().isoformat()
# 按 ad_session_id 匹配客户端 eCPM 上报:既用于缺 eCPM 时回退取值,也把「来源」
# (我们的应用 app_env + 我们配置的代码位 our_code_id)回填到发奖记录,供广告收益报表聚合。
# S2S 回调本身不带这俩;查不到(未上报 eCPM)则留空。
ecpm_rec = (
crud_ecpm.find_by_session(db, user_id=user_id, ad_session_id=ad_session_id)
if ad_session_id else None
)
src_app_env = ecpm_rec.app_env if ecpm_rec is not None else None
src_code_id = ecpm_rec.our_code_id if ecpm_rec is not None else None
# #3 每日上限:当前产品只保留发奖次数上限(默认 500 次)。旧的观看时长闸保留字段, # #3 每日上限:当前产品只保留发奖次数上限(默认 500 次)。旧的观看时长闸保留字段,
# 但 DAILY_AD_WATCH_SECONDS_LIMIT=0 时视为停用,不能命中 capped。 # 但 DAILY_AD_WATCH_SECONDS_LIMIT=0 时视为停用,不能命中 capped。
over_time = ( over_time = (
@@ -88,23 +112,24 @@ def grant_ad_reward(
trans_id=trans_id, user_id=user_id, coin=0, status="capped", trans_id=trans_id, user_id=user_id, coin=0, status="capped",
reward_date=today, reward_name=reward_name, raw=raw, reward_date=today, reward_name=reward_name, raw=raw,
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm, reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm,
app_env=src_app_env, our_code_id=src_code_id,
) )
return _commit_record(db, rec, trans_id) return _commit_record(db, rec, trans_id)
ecpm_raw = ecpm ecpm_raw = ecpm or (ecpm_rec.ecpm_raw if ecpm_rec is not None else None)
if not ecpm_raw and ad_session_id:
ecpm_rec = crud_ecpm.find_by_session(db, user_id=user_id, ad_session_id=ad_session_id)
ecpm_raw = ecpm_rec.ecpm_raw if ecpm_rec is not None else None
if not ecpm_raw: if not ecpm_raw:
rec = AdRewardRecord( rec = AdRewardRecord(
trans_id=trans_id, user_id=user_id, coin=0, status="ecpm_missing", trans_id=trans_id, user_id=user_id, coin=0, status="ecpm_missing",
reward_date=today, reward_name=reward_name, raw=raw, reward_date=today, reward_name=reward_name, raw=raw,
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=None, reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=None,
app_env=src_app_env, our_code_id=src_code_id,
) )
return _commit_record(db, rec, trans_id) return _commit_record(db, rec, trans_id)
coin = rewards.calculate_ad_reward_coin(ecpm_raw, _granted_today(db, user_id, today) + 1) # LT 因子(因子2)按"账号累计第 N 次看广告"递减(2.0→1.0),不再按天重置;
# 每日次数上限/冷却仍按当日统计(over_count 用 _granted_today)。
coin = rewards.calculate_ad_reward_coin(ecpm_raw, _granted_cumulative(db, user_id) + 1)
# 发金币 + 记一笔,同事务 # 发金币 + 记一笔,同事务
crud_wallet.grant_coins( crud_wallet.grant_coins(
@@ -115,6 +140,55 @@ def grant_ad_reward(
trans_id=trans_id, user_id=user_id, coin=coin, status="granted", trans_id=trans_id, user_id=user_id, coin=coin, status="granted",
reward_date=today, reward_name=reward_name, raw=raw, reward_date=today, reward_name=reward_name, raw=raw,
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm_raw, reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm_raw,
app_env=src_app_env, our_code_id=src_code_id,
)
return _commit_record(db, rec, trans_id)
def record_reward_noshow(
db: Session,
user_id: int,
*,
ad_session_id: str,
ecpm: str | None = None,
adn: str | None = None,
slot_id: str | None = None,
app_env: str | None = None,
our_code_id: str | None = None,
reward_scene: str = "reward_video",
) -> AdRewardRecord:
"""客户端上报「激励视频展示了但用户提前关/跳过、未触发发奖」,落一条 coin=0 status='closed_early'
记录,供广告收益报表把不发金币的原因也呈现出来(只留痕,不发币)
幂等键 trans_id = 'noreward:{ad_session_id}'(每次展示唯一)若同一 ad_session_id 已有 granted
记录(S2S 已发奖,正常路径),说明用户其实看完了 跳过不写原样返回那条,避免与正常发奖重复
app_env/our_code_id 由客户端直接带上(它本就持有);查不到 user UnknownUserError
"""
if db.get(User, user_id) is None:
raise UnknownUserError
# 同一次展示已正常发奖 → 不再记 closed_early(防与 S2S granted 重复)
granted = db.execute(
select(AdRewardRecord)
.where(
AdRewardRecord.user_id == user_id,
AdRewardRecord.ad_session_id == ad_session_id,
AdRewardRecord.status == "granted",
)
.limit(1)
).scalar_one_or_none()
if granted is not None:
return granted
trans_id = f"noreward:{ad_session_id}"
existing = _find_by_trans(db, trans_id)
if existing is not None:
return existing
rec = AdRewardRecord(
trans_id=trans_id, user_id=user_id, coin=0, status="closed_early",
reward_date=cn_today().isoformat(), reward_name=None, raw=None,
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm,
app_env=app_env, our_code_id=our_code_id,
) )
return _commit_record(db, rec, trans_id) return _commit_record(db, rec, trans_id)
+56 -9
View File
@@ -9,7 +9,7 @@ import logging
from datetime import date, datetime from datetime import date, datetime
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from sqlalchemy import delete, select from sqlalchemy import delete, func, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -31,11 +31,13 @@ def today_cn() -> date:
# ===== 弹窗频控(coupon_prompt_engagement)===== # ===== 弹窗频控(coupon_prompt_engagement)=====
def has_engaged_today(db: Session, device_id: str) -> bool: def has_engaged_today(db: Session, device_id: str, package: str) -> bool:
"""这台设备今天是否已对领券引导窗表达过意向(领或拒)。有 = 不再弹。""" """这台设备今天**这个 App** 是否已对领券引导窗表达过意向(领/拒/弹出)。有 = 该 App 不再弹。
频控按 (device, package, ):美团弹过不影响淘宝/京东今天各自仍弹一次"""
row = db.execute( row = db.execute(
select(CouponPromptEngagement.id).where( select(CouponPromptEngagement.id).where(
CouponPromptEngagement.device_id == device_id, CouponPromptEngagement.device_id == device_id,
CouponPromptEngagement.package == package,
CouponPromptEngagement.engage_date == today_cn(), CouponPromptEngagement.engage_date == today_cn(),
) )
).first() ).first()
@@ -43,13 +45,18 @@ def has_engaged_today(db: Session, device_id: str) -> bool:
def mark_engagement( def mark_engagement(
db: Session, device_id: str, user_id: int | None, engage_type: str db: Session, device_id: str, package: str, user_id: int | None, engage_type: str
) -> None: ) -> None:
"""记今日意向(claim_started / dismissed)。(device, 今天) 唯一,幂等 upsert。""" """记今日意向(shown / claim_started / dismissed)。(device, package, 今天) 唯一,幂等 upsert。
engage_type 升级口径(同一 (device,package,) 多次调,只覆盖 type,不新增行):
shown(自动弹出) claim_started(点一键领取)/ dismissed(点关闭)判断只看"有没有这条"
"""
today = today_cn() today = today_cn()
row = db.execute( row = db.execute(
select(CouponPromptEngagement).where( select(CouponPromptEngagement).where(
CouponPromptEngagement.device_id == device_id, CouponPromptEngagement.device_id == device_id,
CouponPromptEngagement.package == package,
CouponPromptEngagement.engage_date == today, CouponPromptEngagement.engage_date == today,
) )
).scalar_one_or_none() ).scalar_one_or_none()
@@ -59,19 +66,20 @@ def mark_engagement(
row.user_id = user_id row.user_id = user_id
else: else:
db.add(CouponPromptEngagement( db.add(CouponPromptEngagement(
device_id=device_id, user_id=user_id, device_id=device_id, package=package, user_id=user_id,
engage_date=today, engage_type=engage_type, engage_date=today, engage_type=engage_type,
)) ))
try: try:
db.commit() db.commit()
except IntegrityError: except IntegrityError:
# 并发下另一请求刚插了同 (device, 日) → 唯一约束撞,回滚忽略(本就幂等)。 # 并发下另一请求刚插了同 (device, package, 日) → 唯一约束撞,回滚忽略(本就幂等)。
db.rollback() db.rollback()
def reset_today_engagement(db: Session, device_id: str) -> int: def reset_today_engagement(db: Session, device_id: str) -> int:
"""删这台设备今天的 engagement(开发设置「重置今日领券弹窗状态」调,测频控用)。 """删这台设备今天**所有 App** 的 engagement(开发设置「重置今日领券弹窗状态」调,测频控用)。
删后 has_engaged_today false,今天又能弹返回删除行数""" 删后 App has_engaged_today false,今天又能弹返回删除行数
(不按 package 过滤:重置是"把今天清干净从头测",清全部 App 最符合预期)"""
result = db.execute( result = db.execute(
delete(CouponPromptEngagement).where( delete(CouponPromptEngagement).where(
CouponPromptEngagement.device_id == device_id, CouponPromptEngagement.device_id == device_id,
@@ -123,6 +131,19 @@ def mark_completed_today(
db.rollback() db.rollback()
def reset_today_completion(db: Session, device_id: str) -> int:
"""删这台设备今天的"已完成"记录(开发设置「重置今日领券弹窗状态」全重置时调)。
删后 has_completed_today false,首页去领取卡恢复可点返回删除行数"""
result = db.execute(
delete(CouponDailyCompletion).where(
CouponDailyCompletion.device_id == device_id,
CouponDailyCompletion.complete_date == today_cn(),
)
)
db.commit()
return result.rowcount or 0
# ===== 领券记录(coupon_claim_record)===== # ===== 领券记录(coupon_claim_record)=====
def record_claims( def record_claims(
@@ -185,3 +206,29 @@ def record_claims(
) )
return 0 return 0
return written return written
# ===== 累计领券数(「我的」页战绩卡「领取优惠券 X 张」)=====
def sum_claimed_count(db: Session, user_id: int) -> int:
"""该用户累计领到的优惠券张数。口径(2026-06-15 用户定):SUM(claimed_count) ——
各成功领券记录的 pricebot 展示张数(claimed_count ,存的是 display_count)之和,
与领券完成时给用户看的本次领了 N 同源
- 只算 status {success, already_claimed}:already_claimed=今日已领过,协议里算已领到;
failed / skipped 不计
- claimed_count 0 的保持 0:那是同 count_group 合并去重项(pricebot 只让一条出数),不重复计;
NULL 的兜底成 1(成功领到至少 1 ;实际 pricebot to_dict 恒下发 display_count,NULL 基本不出现)
- 维度 user_id:登录态领的券才归入登录前匿名领的(user_id 为空)不算(产品可接受)
"""
total = db.execute(
select(
func.coalesce(
func.sum(func.coalesce(CouponClaimRecord.claimed_count, 1)), 0
)
).where(
CouponClaimRecord.user_id == user_id,
CouponClaimRecord.status.in_(("success", "already_claimed")),
)
).scalar_one()
return int(total or 0)
+110 -48
View File
@@ -6,9 +6,16 @@ feed 取最近的成功且省到钱的比价记录(脱敏用户名);不足 N 条
种子是生成规则:用户名可空(随机合成脱敏名,避开同屏撞名)金额是区间(每次随机取值) 种子是生成规则:用户名可空(随机合成脱敏名,避开同屏撞名)金额是区间(每次随机取值)
feed 公平随机抽取(不看 sort_order),所有启用种子都有机会露出 feed 公平随机抽取(不看 sort_order),所有启用种子都有机会露出
脱敏名风格:手机尾号(138****5678)+ 中文昵称(省钱**小张**吃货**达人)混合,看起来像真实 脱敏名规则( feed 统一,按字符算中英文皆适用):
异质用户群真实条按 user_id 确定性合成(同用户恒定);种子留空 / 用户****xxx模板名一律随机 - 有昵称 昵称脱敏:3 +隐藏字数个星+(省钱小能手*** / SaveKingS******g),
合成(自愈历史种子,无需迁移) 2 +(阿强*),1 用户+该字(用户喵);
- 没昵称 合成一个假名(见下)再脱敏,少数露用户+id 3 (用户********618)
真实条:设过昵称的按真实昵称脱敏;当前多数用户没设昵称,直接展示用户****会清一色,故给它们合成
假名后期真实昵称多了,真实条会直接用真实昵称,合成占比自然下降种子 / 兜底同样合成避开同屏撞名;
种子留空 / 用户****xxx模板名一律重新合成(自愈历史种子,无需迁移)
假名合成:纯本地语料**组合生成**(中文姓池×名字池 拼真名 / 中文网络昵称 / 英文昵称 / 英文名带数字尾),
组合空间上万中文为主英文为辅,脱敏后像真实异质用户群**真实条按 `user_id` 播种确定性合成**(同一用户
每次展示恒定不同用户各异),刷新/翻页不变脸;种子 / 兜底用运行时随机源出多样填充不依赖外部库
""" """
from __future__ import annotations from __future__ import annotations
@@ -21,6 +28,7 @@ from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ from app.core.rewards import CN_TZ
from app.models.comparison import ComparisonRecord from app.models.comparison import ComparisonRecord
from app.models.ops_marquee_seed import OpsMarqueeSeed from app.models.ops_marquee_seed import OpsMarqueeSeed
from app.models.user import User
# feed 运行时随机源(每次请求结果不同 = 轮播想要的「鲜活感」) # feed 运行时随机源(每次请求结果不同 = 轮播想要的「鲜活感」)
_rng = random.Random() _rng = random.Random()
@@ -36,59 +44,111 @@ _REAL_ROWS_TTL_SECONDS = 30
_REAL_ROWS_FETCH_CAP = 600 # 一次多取些,够 limit≤30 去重后取数;命中缓存后复用 _REAL_ROWS_FETCH_CAP = 600 # 一次多取些,够 limit≤30 去重后取数;命中缓存后复用
_real_rows_cache: dict = {"at": None, "rows": None} _real_rows_cache: dict = {"at": None, "rows": None}
# ===== 脱敏名生成:手机尾号 + 中文昵称 混合,看起来像真实异质用户群(替代旧的「用户********xxx」统一模板)===== # ===== 用户标识脱敏 + 没真实昵称时的假名合成 =====
_PHONE_2ND = "3456789" # 手机号第 2 位(13x~19x) # 脱敏规则(中英文皆适用,按字符算):有昵称→≥3 字「首+隐藏字数个星+末」(省钱小能手→省***手 /
_SURNAMES = "王李张刘陈杨黄赵周吴徐孙马朱胡郭何高林罗郑梁谢宋唐许韩冯邓曹彭曾肖田董袁潘蒋蔡余杜叶程苏魏吕丁任沈姚卢" # SaveKing→S******g)、2 字「首+星」(阿强→阿*)、1 字「用户+该字」(喵→用户喵);没昵称→用户+8星+id 后 3 位。
_NICK_WORDS = [ # 没真实昵称时(当前多数用户 / 种子 / 兜底)合成假名:本地姓/名字池组合出真名 + 网络昵称语料(中/英)
"省钱", "吃货", "羊毛", "薅羊毛", "爱比价", "精打细算", "持家", "干饭", # 混播再走同一套脱敏,像真实异质用户群。后期真实昵称多了,真实条直接用真实昵称,这里占比自然下降。
"美食控", "外卖", "剁手", "捡漏", "搞钱", "打工人", "摸鱼", "养生", _ANON_STARS = "*" * 8 # 无昵称匿名串固定 8 星(用户********xxx)
"佛系", "躺平", "暴富", "锦鲤", "夜宵", "奶茶", "热爱生活", "会过日子",
# 中文真名组合池:姓(百家姓常见 ~100)× 名字单字(~120)拼「姓 + 1~2 字」,组合空间上万。
# 脱敏后只露「姓*」或「姓*末」,故无需真实姓名库——组合够多即看着各异(替代原 Faker zh_CN)。
_SURNAME_ZH = list(
"王李张刘陈杨赵黄周吴徐孙胡朱高林何郭马罗梁宋郑谢韩唐冯于董萧程曹袁邓许傅沈曾彭吕苏卢蒋蔡贾丁"
"魏薛叶阎余潘杜戴夏钟汪田任姜范方石姚谭廖邹熊金陆郝孔白崔康毛邱秦江史顾侯邵孟龙万段钱汤尹黎"
"易常武乔贺赖龚文施洪丰房邢"
)
_GIVEN_ZH = list(
"伟芳娜秀英敏静丽强磊军洋勇艳杰娟涛明超霞平刚桂兰华健世广义兴良海山仁波宁贵福生龙元全国胜学祥"
"才发新利清飞彬富顺信子昌成康星光天达安岩中茂进有坚和彪博诚先敬震振壮会思群豪心邦承乐绍功松善"
"厚庆民友裕河哲亮政谦亨奇之轮翰朗伯宏言若鸣朋斌栋维启克伦翔旭鹏泽晨辰士以建家致树炎德行时泰盛雄"
)
# 英文名池:与英文昵称互补,偶尔带数字尾(jay87)更像注册用户名(替代原 Faker en_US)。
_FIRST_EN = [
"Alex", "Chris", "Sam", "Jamie", "Taylor", "Jordan", "Casey", "Morgan", "Riley", "Jessie",
"Robin", "Drew", "Lee", "Max", "Kim", "Ray", "Dana", "Pat", "Terry", "Jay",
"Kelly", "Sky", "Ash", "Nico", "Remy", "Quinn", "Reese", "Sage", "Toni", "Val",
]
# 网络昵称语料:补一份「省钱小能手 / SaveKing」式网络昵称更像真实 app 用户。想更丰富往里加即可。
_NICK_ZH = [
"省钱小能手", "薅羊毛专业户", "吃货一枚", "干饭人", "奶茶续命", "精打细算过日子",
"会过日子的人", "捡漏王", "外卖救星", "养生少年", "持家有道", "比价狂魔",
"月光族逆袭", "摸鱼达人", "暴富锦鲤", "美食猎人", "折扣猎手", "隐形贫困人口",
"打工不打烊", "佛系青年", "元气满满", "一只小馋猫", "爱吃的小朋友", "省钱才是硬道理",
]
_NICK_EN = [
"SaveKing", "DealHunter", "FoodieLife", "BudgetBoss", "CouponQueen", "ThriftyOne",
"SnackLover", "BargainHero", "PennyWise", "SmartShopper", "DiscountNinja", "HungryPanda",
"MoneyMaster", "CheapEats", "SaverPro", "FrugalFox", "NoodleKing", "BubbleTeaFan",
"GoldenSaver", "ValueHunter", "SnackAttack", "LazyFoodie",
] ]
_NICK_PREFIX = ["", "", "", "", "一只", "爱吃的", "快乐的"]
_NICK_SUFFIX = ["达人", "小能手", "", "", "一族", "星人", "", ""]
_LETTER_U = "ABCDEFGHJKLMNPQRSTUVWXYZ"
_LETTER_L = "abcdefghijkmnpqrstuvwxyz"
def _phone_name(rng: random.Random) -> str: def _mask_nickname(nick: str) -> str:
"""手机尾号式:1[3-9]X****XXXX(合成、非真实号)。""" """昵称脱敏(按字符,中英文皆可):≥3 字→首+(隐藏字数个)星+末(省钱小能手→省***手 / SaveKing→S******g);
head = "1" + rng.choice(_PHONE_2ND) + str(rng.randint(0, 9)) 2 +(阿强*);1 用户+该字(用户喵)调用方需保证 nick strip 且非空"""
return f"{head}****{rng.randint(0, 9999):04d}" n = len(nick)
if n >= 3:
return nick[0] + "*" * (n - 2) + nick[-1]
if n == 2:
return nick[0] + "*"
return "用户" + nick
def _nickname(rng: random.Random) -> str: def _anon_by_id(user_id: int) -> str:
"""昵称打码式,中文为主(约 11/12),掺少量中英混 / 纯字母。 """昵称匿名:固定「用户」+ 8 星 + id 后 3 位(不足 3 位前补 0):用户********618。"""
脱敏星号一律夹在中间(xx**yy),星号前后都要有可见字不出现结尾打码(省钱**)的形态 return "用户" + _ANON_STARS + f"{user_id % 1000:03d}"
"""
p = rng.randint(0, 11)
if p in (0, 1, 2, 3, 4):
return rng.choice(_NICK_WORDS) + "**" + rng.choice(_NICK_SUFFIX) # 省钱**党 / 吃货**达人
if p in (5, 6):
return rng.choice(_SURNAMES) + "**" + rng.choice(_NICK_SUFFIX) # 张**侠
if p in (7, 8):
return rng.choice(_NICK_PREFIX) + rng.choice(_SURNAMES) + "**" + rng.choice(_NICK_SUFFIX) # 小张**达人
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) A**b
def _realistic_name(rng: random.Random) -> str: def _synth_full_name(rng: random.Random) -> str:
"""~45% 手机尾号 + ~55% 中文昵称,混合出真实异质感。""" """合成一个完整(未脱敏)假名:中文真名(姓+1~2字) / 中文网络昵称 / 英文昵称 / 英文名(可带数字尾) 混播,
return _phone_name(rng) if rng.random() < 0.45 else _nickname(rng) 中文为主(~70%)英文为辅(~30%)**全程用传入 rng** 同种子复现不同种子各异组合空间上万"""
r = rng.random()
if r < 0.35:
# 中文真名:姓 + 1~2 个名字字(60% 概率两字),脱敏后露「姓*」或「姓*末」
given = rng.choice(_GIVEN_ZH) + (rng.choice(_GIVEN_ZH) if rng.random() < 0.6 else "")
return rng.choice(_SURNAME_ZH) + given # 王伟 / 李秀兰
if r < 0.70:
return rng.choice(_NICK_ZH) # 中文网络昵称:省钱小能手
if r < 0.85:
return rng.choice(_NICK_EN) # 英文昵称:SaveKing
base = rng.choice(_FIRST_EN) # 英文名,半数带数字尾:Jay87 / Alex
return base + (str(rng.randint(1, 99)) if rng.random() < 0.5 else "")
def _mask_user(user_id: int) -> str: def _synth_masked_name(rng: random.Random) -> str:
"""真实用户脱敏:按 user_id 确定性合成一个名(同用户恒定、不暴露真实信息)""" """合成一条「已脱敏」假名(供没真实昵称的真实用户 / 种子 / 兜底用)。用传入 rng 决定一切随机"""
return _realistic_name(random.Random(user_id)) full = _synth_full_name(rng).strip()
return _mask_nickname(full) if full else "用户" + _ANON_STARS + f"{rng.randint(0, 999):03d}"
def _mask_real(nickname: str | None, user_id: int) -> str:
"""真实用户脱敏:设过昵称→昵称脱敏(中英文皆可);没昵称→**按 user_id 播种确定性合成假名**再脱敏
(同一用户每次展示恒定刷新不变脸,不同用户各异),避免无昵称用户清一色用户****xxx;
少数(~15%)用户+真实 id 3 当前多数用户没昵称,后期真实昵称多了占比下降"""
nick = (nickname or "").strip()
if nick:
return _mask_nickname(nick)
# 关键:按 user_id 播种本地 rng → 同一用户的合成名跨请求/刷新恒定(0.15 抽签也用它)。
seeded = random.Random(user_id)
return _anon_by_id(user_id) if seeded.random() < 0.15 else _synth_masked_name(seeded)
def _synth_name(rng: random.Random) -> str:
"""合成一条脱敏名(种子留空 / 旧模板名 / 兜底用):绝大多数=假名脱敏,少数=用户+随机 3 位(用户********618)。"""
if rng.random() < 0.15:
return "用户" + _ANON_STARS + f"{rng.randint(0, 999):03d}"
return _synth_masked_name(rng)
def _unique_name(used: set[str]) -> str: def _unique_name(used: set[str]) -> str:
"""随机合成一个不与 used 撞的脱敏名(种子留空 / 旧模板名用),防同屏撞名。""" """随机合成一个不与 used 撞的脱敏名(种子留空 / 旧模板名 / 兜底用),防同屏撞名。"""
for _ in range(60): for _ in range(60):
n = _realistic_name(_rng) n = _synth_name(_rng)
if n not in used: if n not in used:
return n return n
return _realistic_name(_rng) + str(_rng.randint(0, 99)) # 兜底(几乎不会到) return "用户" + _ANON_STARS + f"{_rng.randint(0, 999):03d}" # 兜底:数字尾号天然好去重
def _validate_amount(min_cents: int, max_cents: int) -> None: def _validate_amount(min_cents: int, max_cents: int) -> None:
@@ -116,8 +176,9 @@ _FALLBACK_MAX_CENTS = 3500
# ===== 用户侧:轮播 feed(真实 + 种子混播) ===== # ===== 用户侧:轮播 feed(真实 + 种子混播) =====
def _recent_real_rows(db: Session) -> list[tuple[int, int]]: def _recent_real_rows(db: Session) -> list[tuple[int, int, str | None]]:
"""近期 success 且省到钱的 (user_id, saved_cents),按 created_at desc;带 ~30s 进程内缓存。 """近期 success 且省到钱的 (user_id, saved_cents, nickname),按 created_at desc;带 ~30s 进程内缓存。
join user 取真实昵称用于脱敏(无昵称则展示层走用户+id 3 )
返回纯元组(脱离 session),可安全跨请求复用极端并发下偶尔多查一次(无锁幂等),纯门面无副作用 返回纯元组(脱离 session),可安全跨请求复用极端并发下偶尔多查一次(无锁幂等),纯门面无副作用
""" """
now = datetime.now(CN_TZ) now = datetime.now(CN_TZ)
@@ -125,7 +186,8 @@ def _recent_real_rows(db: Session) -> list[tuple[int, int]]:
if cached is not None and at is not None and (now - at).total_seconds() < _REAL_ROWS_TTL_SECONDS: if cached is not None and at is not None and (now - at).total_seconds() < _REAL_ROWS_TTL_SECONDS:
return cached return cached
rows = db.execute( rows = db.execute(
select(ComparisonRecord.user_id, ComparisonRecord.saved_amount_cents) select(ComparisonRecord.user_id, ComparisonRecord.saved_amount_cents, User.nickname)
.join(User, User.id == ComparisonRecord.user_id)
.where( .where(
ComparisonRecord.status == "success", ComparisonRecord.status == "success",
ComparisonRecord.saved_amount_cents > 0, ComparisonRecord.saved_amount_cents > 0,
@@ -134,7 +196,7 @@ def _recent_real_rows(db: Session) -> list[tuple[int, int]]:
.order_by(ComparisonRecord.created_at.desc()) .order_by(ComparisonRecord.created_at.desc())
.limit(_REAL_ROWS_FETCH_CAP) .limit(_REAL_ROWS_FETCH_CAP)
).all() ).all()
out = [(int(uid), int(sc)) for uid, sc in rows] out = [(int(uid), int(sc), nick) for uid, sc, nick in rows]
_real_rows_cache["rows"], _real_rows_cache["at"] = out, now _real_rows_cache["rows"], _real_rows_cache["at"] = out, now
return out return out
@@ -155,12 +217,12 @@ def get_feed(db: Session, limit: int = 8) -> list[dict]:
items: list[dict] = [] items: list[dict] = []
used_names: set[str] = set() used_names: set[str] = set()
seen_users: set[int] = set() seen_users: set[int] = set()
for uid, sc in rows: for uid, sc, nick in rows:
if uid in seen_users: if uid in seen_users:
continue continue
seen_users.add(uid) seen_users.add(uid)
name = _mask_user(uid) name = _mask_real(nick, uid)
used_names.add(name) # 真实名按 user_id 稳定;偶发撞名可接受 used_names.add(name) # 真实名按昵称/id 稳定;偶发撞名可接受
items.append({"masked_user": name, "saved_amount_cents": int(sc)}) items.append({"masked_user": name, "saved_amount_cents": int(sc)})
if len(items) >= limit: if len(items) >= limit:
break break
+212
View File
@@ -0,0 +1,212 @@
"""平台店铺映射落库:一次比价(trace)一行,各目标平台解析出 id 就 upsert 进同一行。
合并键 = trace_id(唯一约束)一次跨平台比价 = 一个 trace = 一个真实店铺:淘宝腿解析出
shopId 先建行(填淘宝列),京东腿(将来)解析出 id upsert **同一行**(填京东列)
合并策略 = 填空(fill-the-blanks):只写该行当前为 NULL 的列,绝不覆盖已有非空值保证后到
的平台只填自己那几列动不了先到平台的数据;共享列(geo / source / 溯源)先到先得
不依赖 ON CONFLICT,跨方言(PG / SQLite dev)都安全;并发撞唯一约束则回滚后转走合并路径
一行里 id_taobao id_jd 共存只表示"两条腿搜同一个源店名各自匹配到了某家店",
name-match 置信度非已核实同一实体作为 append-only 原始资产留存,跨平台精确匹配由下游做
"""
from __future__ import annotations
import logging
import math
from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.models.store_mapping import StoreMapping
from app.schemas.store_mapping import StoreMappingIn
logger = logging.getLogger("shagua.store_mapping")
# 源平台 → 该平台店名所在列(缓存查询的匹配键)。镜像 pricebot reporter 的同名表。
_SOURCE_NAME_COLUMN = {
"meituan": "name_meituan",
"meituan_waimai": "name_meituan",
"jd_waimai": "name_jd",
"jd_waimai_standalone": "name_jd",
"taobao_flash": "name_taobao",
}
# 源平台 → 它自己对应的目标 key(查缓存时排除"源平台自己", 不会复用源平台的店铺 id)。
_SOURCE_TARGET_KEY = {
"meituan": "meituan", "meituan_waimai": "meituan",
"jd_waimai": "jd", "jd_waimai_standalone": "jd",
"taobao_flash": "taobao",
}
# upsert 填空时可写的列。不含: trace_id(合并键)/ business_type(非空默认)/
# id(主键)/ created_at(server_default)。各平台只会带自己那几列非空, 其余为 None 不动。
_MERGE_COLUMNS = (
"source_platform",
"id_taobao", "name_taobao", "id_meituan", "name_meituan", "id_jd", "name_jd",
"city", "geohash", "lng", "lat", "taobao_address",
"source_device_id", "source_user_id",
"taobao_share_url", "taobao_resolved_url", "taobao_deeplink",
"meituan_poi_id_str", "meituan_share_url", "meituan_resolved_url", "meituan_deeplink",
"jd_vender_id", "jd_share_url", "jd_resolved_url", "jd_deeplink",
"attrs",
)
def _find(db: Session, trace_id: str) -> StoreMapping | None:
return db.execute(
select(StoreMapping).where(StoreMapping.trace_id == trace_id)
).scalar_one_or_none()
def _merge_fill_blanks(existing: StoreMapping, payload: StoreMappingIn) -> list[str]:
"""把 payload 里非空、且 existing 当前为 NULL 的列填进去。返回被填的列名(空=无变化)。"""
filled: list[str] = []
for col in _MERGE_COLUMNS:
new = getattr(payload, col)
if new is not None and getattr(existing, col) is None:
setattr(existing, col, new)
filled.append(col)
return filled
def upsert(db: Session, payload: StoreMappingIn) -> tuple[int, int | None]:
"""落一行跨平台店铺映射,返回 (created, row_id)。
created=1 新建该 trace 的行 / 0 合并进已存在行(填空,不覆盖)"""
existing = _find(db, payload.trace_id)
if existing is None:
# 首写: 把 payload 全部可合并列灌进去(逐列 setattr 而非硬编码构造器, 否则首写的是
# 美团/京东腿时它们的列会漏 —— 不在硬编码列表里就丢)。trace_id/business_type 是键/默认, 显式给。
row = StoreMapping(
trace_id=payload.trace_id,
business_type=payload.business_type,
)
for col in _MERGE_COLUMNS:
setattr(row, col, getattr(payload, col))
db.add(row)
try:
db.commit()
except IntegrityError:
# 并发: 另一个请求刚插了同 trace → 撞唯一约束。回滚后转合并路径填空。
db.rollback()
existing = _find(db, payload.trace_id)
if existing is None:
raise
logger.warning("store_mapping 并发冲突 trace=%s, 转填空合并", payload.trace_id)
else:
db.refresh(row)
return 1, row.id
# 已存在(或并发回退到此): 填空合并, 只写当前 NULL 的列
filled = _merge_fill_blanks(existing, payload)
if filled:
db.commit()
db.refresh(existing)
logger.info("store_mapping 合并 trace=%s 填列=%s", payload.trace_id, filled)
return 0, existing.id
def mark_taobao_deeplink_invalid(db: Session, shop_id: str) -> int:
"""把所有 id_taobao=shop_id 的行标记淘宝 deeplink 失效(置 invalid_at=now)。返回本次新标记的行数。
shopId 标记**所有** 同一个坏 shopId(撞淘宝"页面出错了"降级页)可能散在多次比价的
多行里, 全标掉才能让后续 lookup 不再返回它幂等: 已标记的行(invalid_at NULL)跳过"""
result = db.execute(
update(StoreMapping)
.where(
StoreMapping.id_taobao == shop_id,
StoreMapping.taobao_deeplink_invalid_at.is_(None),
)
.values(taobao_deeplink_invalid_at=func.now())
)
db.commit()
return result.rowcount or 0
def mark_jd_deeplink_invalid(db: Session, store_id: str) -> int:
"""把所有 id_jd=store_id 的行标记京东 deeplink 失效(置 invalid_at=now)。返回本次新标记的行数。
mark_taobao_deeplink_invalid: storeId 标记**所有**(撞京东"当前门店超出配送范围"),
全标掉才能让后续 lookup 不再返回它幂等:已标记的行(invalid_at NULL)跳过"""
result = db.execute(
update(StoreMapping)
.where(
StoreMapping.id_jd == store_id,
StoreMapping.jd_deeplink_invalid_at.is_(None),
)
.values(jd_deeplink_invalid_at=func.now())
)
db.commit()
return result.rowcount or 0
# ============================================================
# 缓存查询: 比价前按"源平台店名"反查已沉淀的各目标平台店铺 id, 命中就让 pricebot 直接
# deeplink 跳店内搜索, 省掉"开平台→进店→分享反查"整段。
# ============================================================
def _haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
"""两点球面距离(km)。仅用于同名候选里挑最近, 精度够用。"""
r = 6371.0
p1, p2 = math.radians(lat1), math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lng2 - lng1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * r * math.asin(math.sqrt(a))
def _pick_best(rows: list[StoreMapping], lat: float | None, lng: float | None) -> StoreMapping:
"""同名 + 含目标 id 的候选里挑一条:有入参 geo 且有候选带 geo → 取最近;否则取 created_at 最新。"""
if lat is not None and lng is not None:
geod = [r for r in rows if r.lat is not None and r.lng is not None]
if geod:
return min(geod, key=lambda r: _haversine_km(lat, lng, r.lat, r.lng))
return max(rows, key=lambda r: r.created_at)
# 目标 key → (该平台店铺 id 列, 组装返回 payload 的函数)。pricebot 拿 id 现拼 deeplink。
_TARGETS = {
"taobao": ("id_taobao", lambda r: {"shop_id": r.id_taobao, "deeplink": r.taobao_deeplink}),
"jd": ("id_jd", lambda r: {"store_id": r.id_jd, "vender_id": r.jd_vender_id, "deeplink": r.jd_deeplink}),
"meituan": ("meituan_poi_id_str", lambda r: {"poi_id_str": r.meituan_poi_id_str, "deeplink": r.meituan_deeplink}),
}
def lookup_nearest(
db: Session, source_platform: str, store_name: str,
lat: float | None = None, lng: float | None = None,
) -> dict:
""""源平台店名"反查各目标平台已沉淀的店铺 id。返回 {target_key: {id..., deeplink, row_id, ...}}。
- 匹配键 = 源平台对应的 name == store_name(精确)
- 每个目标**分别**"含该目标 id 的同名候选里最近一条"(淘宝 id / 京东 id 可能在不同行)
- 排除源平台自己(不复用源平台的店铺 id)命中为空 = 没缓存, pricebot 走现场反查老路"""
name_col = _SOURCE_NAME_COLUMN.get(source_platform)
if not name_col or not store_name:
return {}
rows = db.execute(
select(StoreMapping).where(getattr(StoreMapping, name_col) == store_name)
).scalars().all()
if not rows:
return {}
src_key = _SOURCE_TARGET_KEY.get(source_platform)
out: dict = {}
for tgt, (id_attr, make_payload) in _TARGETS.items():
if tgt == src_key:
continue # 不返回源平台自己
cands = [r for r in rows if getattr(r, id_attr)]
if tgt == "taobao":
# 失效的淘宝 deeplink(撞过错误页被 invalidate)整条排除 = 当没缓存, pricebot 走正常搜店。
cands = [r for r in cands if r.taobao_deeplink_invalid_at is None]
elif tgt == "jd":
# 同上:失效的京东 deeplink(撞"当前门店超出配送范围"被 invalidate)整条排除。
cands = [r for r in cands if r.jd_deeplink_invalid_at is None]
if not cands:
continue
best = _pick_best(cands, lat, lng)
payload = make_payload(best)
payload["row_id"] = best.id
if best.lat is not None and best.lng is not None and lat is not None and lng is not None:
payload["dist_km"] = round(_haversine_km(lat, lng, best.lat, best.lng), 3)
out[tgt] = payload
return out
+9 -3
View File
@@ -78,9 +78,15 @@ class WithdrawNotReviewable(Exception):
"""提现单当前状态不可审核(非 reviewing,可能已被处理过)。""" """提现单当前状态不可审核(非 reviewing,可能已被处理过)。"""
def get_or_create_account(db: Session, user_id: int, *, commit: bool = True) -> CoinAccount: def get_or_create_account(
"""取用户金币账户,不存在则建一个空账户。""" db: Session, user_id: int, *, commit: bool = True, lock: bool = False
acc = db.get(CoinAccount, user_id) ) -> CoinAccount:
"""取用户金币账户,不存在则建一个空账户。
lock=True 时对已存在的账户行加 SELECT FOR UPDATE(--写余额的调用方串行化,防并发
双写余额错位, admin set 模式连点);默认 False 不改 C 端发奖行为SQLite 下为 no-op
"""
acc = db.get(CoinAccount, user_id, with_for_update=True) if lock else db.get(CoinAccount, user_id)
if acc is None: if acc is None:
acc = CoinAccount( acc = CoinAccount(
user_id=user_id, user_id=user_id,
+49 -5
View File
@@ -57,6 +57,12 @@ class EcpmReportIn(BaseModel):
) )
adn: str | None = Field(None, description="实际投放 ADN(getSdkName),如 pangle") adn: str | None = Field(None, description="实际投放 ADN(getSdkName),如 pangle")
slot_id: str | None = Field(None, description="实际展示代码位(底层 mediation rit)") slot_id: str | None = Field(None, description="实际展示代码位(底层 mediation rit)")
app_env: str | None = Field(
None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)"
)
our_code_id: str | None = Field(
None, max_length=64, description="我们后台配置的代码位 ID(AdConfig 的 104xxx,非底层 rit)"
)
class EcpmReportOut(BaseModel): class EcpmReportOut(BaseModel):
@@ -115,24 +121,62 @@ class TestGrantOut(BaseModel):
class FeedRewardIn(BaseModel): class FeedRewardIn(BaseModel):
"""信息流广告完成后结算奖励。 """比价/领券一整场信息流(轮播多条)结束后结算奖励。
每展示满 10 秒累计一份奖励,视频完成后一次性入账client_event_id 用于客户端超时重试幂等 规则:全程不关广告才发,金额按整场**总观看时长**折份( 10 1 )client_event_id 用于
客户端超时重试幂等中途被用户关闭时传 aborted=True,整场不发(只记 closed_early)
""" """
client_event_id: str = Field(..., min_length=8, max_length=64, description="客户端生成的幂等事件 id") client_event_id: str = Field(..., min_length=8, max_length=64, description="客户端生成的幂等事件 id")
ad_session_id: str | None = Field( ad_session_id: str | None = Field(
None, min_length=8, max_length=64, description="客户端生成的一次信息流广告会话 id" None, min_length=8, max_length=64, description="客户端生成的一次信息流广告会话 id"
) )
ecpm: str = Field(..., description="信息流广告 eCPM,按分/千次展示处理(SDK getEcpm 原值,非元)") ecpm: str = Field(..., description="信息流 eCPM(代表值,按分/千次展示处理;SDK getEcpm 原值,非元)")
duration_seconds: int = Field(..., ge=0, description="本条广告实际展示/播放秒数") duration_seconds: int = Field(..., ge=0, description="整场累计观看秒数(轮播各条相加)")
adn: str | None = Field(None, description="实际投放 ADN") adn: str | None = Field(None, description="实际投放 ADN")
slot_id: str | None = Field(None, description="实际展示代码位") slot_id: str | None = Field(None, description="实际展示代码位")
app_env: str | None = Field(
None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)"
)
our_code_id: str | None = Field(
None, max_length=64, description="我们后台配置的代码位 ID(AdConfig 的 104xxx,非底层 rit)"
)
aborted: bool = Field(
False, description="用户中途 ✕ 关闭广告(未走完比价):整场不发,记 closed_early"
)
class FeedRewardOut(BaseModel): class FeedRewardOut(BaseModel):
granted: bool = Field(..., description="本次是否入账。达上限时 false") granted: bool = Field(..., description="本次是否入账。达上限时 false")
status: str = Field(..., description="granted / capped") status: str = Field(..., description="granted / capped / too_short / closed_early")
coin: int = Field(..., description="本次发放金币") coin: int = Field(..., description="本次发放金币")
unit_count: int = Field(..., description="按 10 秒折算出的奖励份数") unit_count: int = Field(..., description="按 10 秒折算出的奖励份数")
daily_limit: int = Field(..., description="每日信息流展示次数上限") daily_limit: int = Field(..., description="每日信息流展示次数上限")
class RewardNoShowIn(BaseModel):
"""激励视频展示了但用户提前关闭/跳过、未触发发奖——上报留痕(不发金币)。
供广告收益报表把有展示没发金币的原因也记录下来user_id JWT ,不在 body
"""
ad_session_id: str = Field(
..., min_length=8, max_length=64, description="本次展示会话 id(与 ecpm 上报、S2S extra 一致)"
)
watched_seconds: int = Field(0, ge=0, description="关闭前已观看秒数(仅留痕参考,不入库)")
ecpm: str | None = Field(None, description="本次展示 eCPM(分/千次,SDK getEcpm 原值);可空")
adn: str | None = Field(None, description="实际投放 ADN")
slot_id: str | None = Field(None, description="实际展示代码位(底层 mediation rit)")
app_env: str | None = Field(
None, max_length=16, description="我们的穿山甲应用环境:prod / test"
)
our_code_id: str | None = Field(
None, max_length=64, description="我们后台配置的代码位 ID(104xxx)"
)
class RewardNoShowOut(BaseModel):
ok: bool = Field(..., description="是否处理成功(落库或幂等命中)")
status: str = Field(
..., description="closed_early(已留痕) / granted(该次其实已发奖,跳过未写)"
)
+11
View File
@@ -24,6 +24,13 @@ class ComparisonItemIn(BaseModel):
specs: list[str] | None = None specs: list[str] | None = None
class AppliedCouponIn(BaseModel):
"""单笔已用优惠(来自 comparison_results[].applied_coupons)。amount 单位:元、正数。"""
name: str
amount: float
class ComparisonResultIn(BaseModel): class ComparisonResultIn(BaseModel):
"""逐平台对比项(来自 done.params.comparison_results)。price 单位:元。""" """逐平台对比项(来自 done.params.comparison_results)。price 单位:元。"""
@@ -40,6 +47,10 @@ class ComparisonResultIn(BaseModel):
# 优惠**来源名**(展示用, best-effort): 美团"外卖大额神券"/京东"百亿补贴"/淘宝"平台红包"。 # 优惠**来源名**(展示用, best-effort): 美团"外卖大额神券"/京东"百亿补贴"/淘宝"平台红包"。
# None=没抠到 → 前端走通用"红包"。同样必须显式声明否则上报边界被 pydantic 静默丢弃(pricebot#38 引入)。 # None=没抠到 → 前端走通用"红包"。同样必须显式声明否则上报边界被 pydantic 静默丢弃(pricebot#38 引入)。
coupon_name: str | None = None coupon_name: str | None = None
# 多券明细 [{name, amount}](全口径: 平台红包+商家券+满减+配送减免, amount 单位元正数)。
# 跟 coupon_saved 并存, 是更丰富的明细; 空=没抠到 → 前端回退单券路径。
# 必须显式声明: 落库走 model_dump(), pydantic 默认丢未知字段, 不声明这行会被悄悄吞掉。
applied_coupons: list[AppliedCouponIn] = Field(default_factory=list)
class ComparisonRecordIn(BaseModel): class ComparisonRecordIn(BaseModel):
+26 -2
View File
@@ -7,16 +7,30 @@ from pydantic import BaseModel
class CouponPromptDismissIn(BaseModel): class CouponPromptDismissIn(BaseModel):
"""客户端拒绝/关闭领券引导窗的通知体。 """客户端拒绝/关闭领券引导窗的通知体。
server 据此记一条今日 engagement(dismissed) 今天这台设备不再弹引导窗 server 据此记一条今日 engagement(dismissed) 今天**这个 App** 不再弹引导窗
频控按 (device, package, ): App 独立package 缺省 ""(老客户端兼容,退化为全局态)
MVP 不鉴权, device_id 判断;user_id 登录态带上就一并记(资产),可空 MVP 不鉴权, device_id 判断;user_id 登录态带上就一并记(资产),可空
""" """
device_id: str device_id: str
package: str = ""
user_id: int | None = None
class CouponPromptShownIn(BaseModel):
"""客户端弹出领券引导窗即上报(记 shown)。
弹出那刻就记一条今日 engagement(shown) 今天**这个 App** 不再自动弹(频控主判据,
管跨重装;本地 SP 兜后台抖动)后续用户点领取/拒绝再把 type 升级成 claim_started/dismissed
"""
device_id: str
package: str
user_id: int | None = None user_id: int | None = None
class CouponPromptShouldShowOut(BaseModel): class CouponPromptShouldShowOut(BaseModel):
"""切到外卖 App 时是否还应弹领券引导窗。今天已 engage(领或拒)过 → false。""" """切到外卖 App 时是否还应弹领券引导窗。今天**这个 App** 已 engage(弹/领/拒)过 → false。"""
should_show: bool should_show: bool
@@ -25,3 +39,13 @@ class CouponCompletedTodayOut(BaseModel):
"""这台设备今天是否已跑完整轮领券(到 done 帧)。完成 → 首页「去领取」卡置灰。""" """这台设备今天是否已跑完整轮领券(到 done 帧)。完成 → 首页「去领取」卡置灰。"""
completed: bool completed: bool
class CouponStatsOut(BaseModel):
"""「我的」页战绩卡「领取优惠券 X 张」数据源:该登录用户累计领到的券数。
口径(2026-06-15 用户定):SUM(claimed_count) 各成功领券记录的 pricebot 展示张数之和,
与领券完成时给用户看的本次领了 N 同源详见 repositories.coupon_state.sum_claimed_count
"""
coupon_count: int
+76
View File
@@ -0,0 +1,76 @@
"""平台店铺映射内部上报的收发模型。
pricebot 在淘宝比价拿到 shopId serverserver POST 一行映射所有字段可空(按比价
角色稀疏填充),server 端只校验共享密钥 + 幂等(trace_id)落库
"""
from __future__ import annotations
from pydantic import BaseModel
class StoreMappingIn(BaseModel):
"""一次比价的跨平台店铺映射上报体(一行)。"""
trace_id: str
business_type: str = "food"
source_platform: str | None = None
# 跨平台身份(按角色稀疏填充)
id_taobao: str | None = None
name_taobao: str | None = None
id_meituan: str | None = None
name_meituan: str | None = None
id_jd: str | None = None
name_jd: str | None = None
# 地理
city: str | None = None
geohash: str | None = None
lng: float | None = None
lat: float | None = None
taobao_address: str | None = None
# 溯源
source_device_id: str | None = None
source_user_id: int | None = None
# 淘宝原料(可复跳 / 可重解析 / 调试)
taobao_share_url: str | None = None
taobao_resolved_url: str | None = None
taobao_deeplink: str | None = None
# 美团原料(poi_id_str 非稳定主键, 单列存; 见 model 注释)
meituan_poi_id_str: str | None = None
meituan_share_url: str | None = None
meituan_resolved_url: str | None = None
meituan_deeplink: str | None = None
# 京东原料(venderId 单列存, storeId 进 id_jd; 见 model 注释)
jd_vender_id: str | None = None
jd_share_url: str | None = None
jd_resolved_url: str | None = None
jd_deeplink: str | None = None
attrs: dict | None = None
class StoreMappingOut(BaseModel):
"""上报结果。inserted=1 为新建该 trace 行,0 为合并进已存在行(填空,不覆盖);
row_id 为该 trace 对应行 id"""
inserted: int
row_id: int | None = None
class StoreMappingInvalidateIn(BaseModel):
"""标记某平台某 shopId 的缓存 deeplink 失效(pricebot 撞错误页回退时上报)。"""
platform: str # 目前只支持 "taobao"
shop_id: str # 失效的店铺 id(淘宝 = shopId = id_taobao)
class StoreMappingInvalidateOut(BaseModel):
"""标记结果。affected = 本次新标记失效的行数(按 shopId 标记所有匹配行)。"""
ok: bool
affected: int
+23 -20
View File
@@ -63,6 +63,7 @@
| 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad-test-grant.md) | | 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad-test-grant.md) |
| 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad-ecpm-report.md) | | 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad-ecpm-report.md) |
| 35a | `POST /api/v1/ad/feed-reward` | Bearer | [详情](./ad-feed-reward.md) | | 35a | `POST /api/v1/ad/feed-reward` | Bearer | [详情](./ad-feed-reward.md) |
| 35b | `POST /api/v1/ad/reward-noshow` | Bearer | [详情](./ad-reward-noshow.md)(激励视频提前关闭/未发奖留痕,只记原因不发币) |
| **用户资料**(前缀 `/api/v1/user` ||| | **用户资料**(前缀 `/api/v1/user` |||
| 35 | `PATCH /api/v1/user/profile` | Bearer | [详情](./user-profile.md) | | 35 | `PATCH /api/v1/user/profile` | Bearer | [详情](./user-profile.md) |
| 36 | `POST /api/v1/user/avatar` | Bearer | [详情](./user-avatar.md) | | 36 | `POST /api/v1/user/avatar` | Bearer | [详情](./user-avatar.md) |
@@ -83,26 +84,28 @@
| A5 | `GET /admin/api/users/{user_id}` | admin | [详情](./admin-user-detail.md) | | A5 | `GET /admin/api/users/{user_id}` | admin | [详情](./admin-user-detail.md) |
| A6 | `POST /admin/api/users/{user_id}/status` | operator | [详情](./admin-user-status.md) | | A6 | `POST /admin/api/users/{user_id}/status` | operator | [详情](./admin-user-status.md) |
| A7 | `POST /admin/api/users/{user_id}/coins` | finance | [详情](./admin-user-coins.md) | | A7 | `POST /admin/api/users/{user_id}/coins` | finance | [详情](./admin-user-coins.md) |
| A8 | `GET /admin/api/wallet/coin-transactions` | admin | [详情](./admin-wallet-coin-transactions.md) | | A8 | `POST /admin/api/users/{user_id}/cash` | finance | [详情](./admin-user-cash.md) |
| A9 | `GET /admin/api/wallet/cash-transactions` | admin | [详情](./admin-wallet-cash-transactions.md) | | A9 | `GET /admin/api/wallet/coin-transactions` | admin | [详情](./admin-wallet-coin-transactions.md) |
| A10 | `GET /admin/api/withdraws` | admin | [详情](./admin-withdraws-list.md) | | A10 | `GET /admin/api/wallet/cash-transactions` | admin | [详情](./admin-wallet-cash-transactions.md) |
| A11 | `POST /admin/api/withdraws/reconcile` | finance | [详情](./admin-withdraw-reconcile.md) | | A11 | `GET /admin/api/withdraws` | admin | [详情](./admin-withdraws-list.md) |
| A12 | `POST /admin/api/withdraws/{out_bill_no}/refresh` | finance | [详情](./admin-withdraw-refresh.md) | | A12 | `POST /admin/api/withdraws/reconcile` | finance | [详情](./admin-withdraw-reconcile.md) |
| A13 | `GET /admin/api/feedbacks` | admin | [详情](./admin-feedbacks-list.md) | | A13 | `POST /admin/api/withdraws/{out_bill_no}/refresh` | finance | [详情](./admin-withdraw-refresh.md) |
| A14 | `POST /admin/api/feedbacks/{feedback_id}/handle` | operator | [详情](./admin-feedback-handle.md) | | A14 | `GET /admin/api/feedbacks` | admin | [详情](./admin-feedbacks-list.md) |
| A15 | `GET /admin/api/admins` | super_admin | [详情](./admin-admins-list.md) | | A15 | `POST /admin/api/feedbacks/{feedback_id}/handle` | operator | [详情](./admin-feedback-handle.md) |
| A16 | `POST /admin/api/admins` | super_admin | [详情](./admin-admin-create.md) | | A16 | `GET /admin/api/admins` | super_admin | [详情](./admin-admins-list.md) |
| A17 | `PATCH /admin/api/admins/{admin_id}` | super_admin | [详情](./admin-admin-update.md) | | A17 | `POST /admin/api/admins` | super_admin | [详情](./admin-admin-create.md) |
| A18 | `GET /admin/api/audit-logs` | admin | [详情](./admin-audit-logs.md) | | A18 | `PATCH /admin/api/admins/{admin_id}` | super_admin | [详情](./admin-admin-update.md) |
| A19 | `GET /admin/api/dashboard-display` | admin | [详情](./admin-dashboard-display.md) | | A19 | `GET /admin/api/audit-logs` | admin | [详情](./admin-audit-logs.md) |
| A20 | `PATCH /admin/api/dashboard-display/{metric}` | operator | [详情](./admin-dashboard-display.md) | | A20 | `GET /admin/api/dashboard-display` | admin | [详情](./admin-dashboard-display.md) |
| A21 | `GET /admin/api/marquee-seeds` | admin | [详情](./admin-marquee-seeds.md) | | A21 | `PATCH /admin/api/dashboard-display/{metric}` | operator | [详情](./admin-dashboard-display.md) |
| A22 | `POST /admin/api/marquee-seeds` | operator | [详情](./admin-marquee-seeds.md) | | A22 | `GET /admin/api/marquee-seeds` | admin | [详情](./admin-marquee-seeds.md) |
| A23 | `PATCH /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) | | A23 | `POST /admin/api/marquee-seeds` | operator | [详情](./admin-marquee-seeds.md) |
| A24 | `DELETE /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) | | A24 | `PATCH /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) |
| A25 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin-marquee-seeds.md) | | A25 | `DELETE /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) |
| A26 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./admin-marquee-seeds.md) | | A26 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin-marquee-seeds.md) |
| A27 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin-ad-coin-audit.md)(看广告金币公式复算对账,只读) | | A27 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./admin-marquee-seeds.md) |
| A28 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin-ad-coin-audit.md)(看广告金币公式复算对账,只读) |
| A29 | `GET /admin/api/ad-revenue-report` | admin | [详情](./admin-ad-revenue-report.md)(广告收益报表:按用户/日期/类型/应用/代码位 聚合 条数/收益/金币,只读) |
| - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) | | - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) |
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link``sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。 > ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link``sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
+6 -4
View File
@@ -7,11 +7,13 @@
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|---|---|---|---| |---|---|---|---|
| `ad_type` | str | 是 | 广告类型:`reward_video`(激励视频) / `draw`(Draw 信息流) 等 | | `ad_type` | str | 是 | 广告类型:`reward_video`(激励视频) / `feed`(信息流) / `draw`(历史 Draw 信息流) 等 |
| `ad_session_id` | str\|null | 否 | 客户端生成的广告会话 ID;需和穿山甲 `extra.ad_session_id` 一致,用于 S2S 缺 eCPM 时匹配 | | `ad_session_id` | str\|null | 否 | 客户端生成的广告会话 ID;激励视频与穿山甲 `extra.ad_session_id` 一致,用于 S2S 缺 eCPM 时匹配。**信息流轮播每条展示用各自独立 id**(不复用比价会话 id,否则 `uq_ad_ecpm_record_session` 去重只留一条) |
| `ecpm` | str | 是 | 穿山甲 `getShowEcpm().getEcpm()` 原始字符串,单位是**分/千次展示**(非元),后端 ÷100 转元参与金币公式 | | `ecpm` | str | 是 | 穿山甲 `getShowEcpm().getEcpm()` 原始字符串,单位是**分/千次展示**(非元),后端 ÷100 转元参与金币公式 |
| `adn` | str\|null | 否 | 实际投放 ADN(`getSdkName`),如 `pangle` | | `adn` | str\|null | 否 | 实际投放 ADN(`getSdkName`),如 `pangle`(聚合后实际填充的子渠道) |
| `slot_id` | str\|null | 否 | 实际展示代码位(底层 mediation rit,非客户端配置位) | | `slot_id` | str\|null | 否 | 实际展示代码位(底层 mediation rit,非客户端配置位) |
| `app_env` | str\|null | 否 | **我们的**穿山甲应用环境:`prod`(傻瓜比价正式) / `test`(测试应用) |
| `our_code_id` | str\|null | 否 | **我们后台配置的**代码位 ID(`AdConfig` 的 104xxx,**非** `slot_id` 的底层 rit) |
`user_id` 不在 body 里——由 JWT 取(Bearer),防伪造。 `user_id` 不在 body 里——由 JWT 取(Bearer),防伪造。
@@ -23,7 +25,7 @@
| `ok` | bool | 落库即 `true` | | `ok` | bool | 落库即 `true` |
## 说明 ## 说明
客户端在广告**展示后**(`onAdShow``getShowEcpm()`)调用,把本次展示的 eCPM 落库做**内部收益统计/对账**。 客户端在广告**展示后**(`onAdShow``getShowEcpm()`)调用,把本次展示的 eCPM 落库做**内部收益统计/对账**。激励视频每次展示上报一条;**信息流轮播每条展示各上报一条**(每条独立 `ad_session_id`),作为「广告收益报表」展示条数/收益的数据源(见 [admin-ad-revenue-report](./admin-ad-revenue-report.md))。`app_env` + `our_code_id` 供报表按「我们的应用 / 我们配置的代码位」聚合,与 `adn`/`slot_id`(底层填充渠道/rit)是两组不同口径。
- 普通激励视频发奖会先用 S2S 回调自带 `ecpm`;若缺失,再按 `ad_session_id` 读取本接口上报的 eCPM;两边都没有则不发并记录异常。 - 普通激励视频发奖会先用 S2S 回调自带 `ecpm`;若缺失,再按 `ad_session_id` 读取本接口上报的 eCPM;两边都没有则不发并记录异常。
- **best-effort**:客户端 fire-and-forget,但普通激励视频若 S2S 缺 eCPM,这条上报会成为发奖依据。 - **best-effort**:客户端 fire-and-forget,但普通激励视频若 S2S 缺 eCPM,这条上报会成为发奖依据。
+12 -9
View File
@@ -1,6 +1,6 @@
# POST /api/v1/ad/feed-reward — 信息流广告完成后结算金币 # POST /api/v1/ad/feed-reward — 信息流广告完成后结算金币
点位 2:比价等待 / 领券信息流广告。每展示满 10 秒累计一份奖励,视频完成后一次性入账 点位 2:比价等待 / 领券信息流广告(轮播多条)。**整场比价全程不关广告才发**,金额按整场**总观看时长**折份(每 10 秒 1 份),结束时一次性入账。用户中途 ✕ 关闭则整场不发
## 鉴权 ## 鉴权
@@ -12,24 +12,27 @@
|---|---|---:|---| |---|---|---:|---|
| `client_event_id` | string | 是 | 客户端生成的幂等事件 id,8-64 字符 | | `client_event_id` | string | 是 | 客户端生成的幂等事件 id,8-64 字符 |
| `ad_session_id` | string\|null | 否 | 客户端生成的一次信息流广告会话 id,用于对账/排查 | | `ad_session_id` | string\|null | 否 | 客户端生成的一次信息流广告会话 id,用于对账/排查 |
| `ecpm` | string | 是 | 本信息流广告 eCPM(穿山甲 getEcpm 原值),按“分/千次展示”处理(非元) | | `ecpm` | string | 是 | 本信息流 eCPM 代表值(穿山甲 getEcpm 原值),按“分/千次展示”处理(非元) |
| `duration_seconds` | int | 是 | 实际展示/播放秒数 | | `duration_seconds` | int | 是 | **整场累计观看秒数**(轮播各条相加) |
| `adn` | string\|null | 否 | 实际投放 ADN | | `adn` | string\|null | 否 | 实际投放 ADN(聚合后实际填充的子渠道) |
| `slot_id` | string\|null | 否 | 实际展示代码位 | | `slot_id` | string\|null | 否 | 实际展示代码位(底层 mediation rit) |
| `app_env` | string\|null | 否 | **我们的**应用环境:`prod`(傻瓜比价正式) / `test`(测试应用) |
| `our_code_id` | string\|null | 否 | **我们后台配置的**代码位 ID(104xxx,非底层 rit);供广告收益报表按代码位聚合金币 |
| `aborted` | bool | 否 | 用户中途 ✕ 关闭广告(未走完比价):整场不发,仅记 `closed_early`。默认 `false` |
## 响应 ## 响应
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|---|---|---| |---|---|---|
| `granted` | bool | 本次是否入账;达上限时为 `false` | | `granted` | bool | 本次是否入账;未发(任一非 granted 状态)时为 `false` |
| `status` | string | `granted` / `capped` | | `status` | string | `granted`(已发) / `capped`(当日次数超限) / `too_short`(整场总时长<10s 凑不满一份) / `closed_early`(用户中途关闭) |
| `coin` | int | 本次发放金币 | | `coin` | int | 本次发放金币;非 granted 为 0 |
| `unit_count` | int | 按 10 秒折算出的奖励份数 | | `unit_count` | int | 按 10 秒折算出的奖励份数 |
| `daily_limit` | int | 每日信息流展示次数上限,默认 500 | | `daily_limit` | int | 每日信息流展示次数上限,默认 500 |
## 计算口径 ## 计算口径
- 奖励份数:`duration_seconds // 10` - 奖励份数:`整场总时长 // 10`
- 单份奖励:`eCPM / 1000 × 因子1(eCPM 档) × 因子2(当天累计份序号) × 10000`,四舍五入为整数金币。 - 单份奖励:`eCPM / 1000 × 因子1(eCPM 档) × 因子2(当天累计份序号) × 10000`,四舍五入为整数金币。
- eCPM 档:`0-100=0.1``101-200=0.3``201-400=0.4``>400=0.6` - eCPM 档:`0-100=0.1``101-200=0.3``201-400=0.4``>400=0.6`
- LT 档:第 1 份 `2.0`,第 2 份 `1.5`,第 3 份 `1.3`,第 4-10 份 `1.1`,第 11 份及以后 `1.0` - LT 档:第 1 份 `2.0`,第 2 份 `1.5`,第 3 份 `1.3`,第 4-10 份 `1.1`,第 11 份及以后 `1.0`
+41
View File
@@ -0,0 +1,41 @@
# POST /api/v1/ad/reward-noshow — 激励视频提前关闭/未发奖留痕
激励视频**展示了但用户提前关闭/跳过、未触发 S2S 发奖**时,客户端 best-effort 上报一条留痕记录,
让运营后台「广告数据」能呈现「有展示、没发金币」的原因。**不发金币**。
## 鉴权
需要 Bearer token。`user_id` 由 JWT 取,不在 body。
## 请求体
| 字段 | 类型 | 必填 | 说明 |
|---|---|---:|---|
| `ad_session_id` | string | 是 | 本次展示会话 id(与 eCPM 上报、S2S `extra.ad_session_id` 一致)8-64 字符 |
| `watched_seconds` | int | 否 | 关闭前已观看秒数(仅留痕参考,**不入库**)。默认 0 |
| `ecpm` | string\|null | 否 | 本次展示 eCPM(穿山甲 getEcpm 原值,分/千次展示) |
| `adn` | string\|null | 否 | 实际投放 ADN(聚合后实际填充子渠道) |
| `slot_id` | string\|null | 否 | 实际展示代码位(底层 mediation rit) |
| `app_env` | string\|null | 否 | **我们的**应用环境:`prod`(傻瓜比价正式) / `test`(测试应用) |
| `our_code_id` | string\|null | 否 | **我们后台配置的**代码位 ID(104xxx,非底层 rit) |
## 响应
| 字段 | 类型 | 说明 |
|---|---|---|
| `ok` | bool | 是否处理成功(落库或幂等命中) |
| `status` | string | `closed_early`(已留痕) / `granted`(该次其实已发奖,跳过未写) |
## 说明
- 在 `onAdClose` 时若本次**未触发发奖**(无 `onRewardVerify`/`onRewardArrived`)调用。
- **幂等**:写入 `ad_reward_record``trans_id = noreward:{ad_session_id}`(每次展示唯一),重试不重复。
- **防与正常发奖重复**:若同一 `ad_session_id` 已有 `status='granted'` 记录(S2S 已发,说明用户其实看完了),
则跳过不写,返回 `status='granted'`
- best-effort:客户端 fire-and-forget,失败只 log,不影响主流程。
- 信息流的「用户中途关闭」走 [ad-feed-reward](./ad-feed-reward.md) 的 `aborted=true`(记 `closed_early`),不走本接口。
## 数据写入
- `ad_reward_record` 新增一行(`coin=0``status='closed_early'``reward_scene='reward_video'`)。
- **不**写 `coin_account` / `coin_transaction`(不发币)。
+6 -4
View File
@@ -25,16 +25,18 @@ eCPM元 = getEcpm分 ÷ 100
| `date` | string | 今天 | 北京时间 `YYYY-MM-DD`,审计某天 | | `date` | string | 今天 | 北京时间 `YYYY-MM-DD`,审计某天 |
| `user_id` | int | 全部 | 只看某用户;不传=所有用户 | | `user_id` | int | 全部 | 只看某用户;不传=所有用户 |
| `scene` | string | 两类 | `reward_video` / `feed`;不传=两类都返回 | | `scene` | string | 两类 | `reward_video` / `feed`;不传=两类都返回 |
| `limit` | int(1~500) | 100 | 返回明细条数(按时间倒序截断;**份序号在截断前已按全天数据算好**,不影响复算正确性) | | `limit` | int(1~500) | 100 | **展示**明细条数(按时间倒序截断;**份序号在截断前已按全天数据算好**,不影响复算正确性) |
| `only_mismatch` | bool | false | 只展示不一致(✗)行;统计数仍按全量,不受影响 |
- 出参 `200`:`AdCoinAuditOut` - 出参 `200`:`AdCoinAuditOut`
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|---|---|---| |---|---|---|
| `date` | string | 审计日期 | | `date` | string | 审计日期 |
| `formula` | object | 当前公式参数快照(见下) | | `formula` | object | 当前公式参数快照(见下) |
| `total` | int | 返回明细条数 | | `total` | int | 该筛选下复算**总条数**(全量,不受 `limit`/`only_mismatch` 影响) |
| `mismatch_count` | int | 其中 `matched=false` 的条数;**=0 说明全部按公式发放** | | `mismatch_count` | int | **全量**不一致条数(截断前统计,可信);**=0 说明全部按公式发放** |
| `items` | `AdCoinAuditRow[]` | 明细(见下) | | `truncated` | bool | 展示集是否被 `limit` 截断(true=还有未返回的明细,请缩小范围或调大 `limit`) |
| `items` | `AdCoinAuditRow[]` | 展示明细(见下);`only_mismatch=true` 时只含 ✗ 行 |
### AdCoinFormulaOut(`formula`) ### AdCoinFormulaOut(`formula`)
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
+108
View File
@@ -0,0 +1,108 @@
# Admin 广告收益报表
> 所属:Admin 组(前缀 `/admin/api/ad-revenue-report`) | 鉴权:Admin Bearer(任意已登录 admin,只读) | [← 返回 API 索引](./README.md)
**用户 × 日期 × 广告类型 × 我们的应用 × 我们的代码位** 聚合,回答「每个用户某天、每类广告(激励视频 / 信息流 / 历史 Draw)分别**看了多少条**、**收益多少**、按现算法**发了多少金币**、广告来自**哪个应用的哪个代码位**」。**纯只读**,不发币、不改数据,也**不改发奖逻辑**。
相关表:[ad_ecpm_record](../database/ad_ecpm_record.md)、[ad_reward_record](../database/ad_reward_record.md)、[ad_feed_reward_record](../database/ad_feed_reward_record.md)。
## 数据来源(三流合并,聚合键 = user × ad_type × app_env × our_code_id)
| 指标 | 来源表 | 口径 |
|---|---|---|
| 展示条数 `impressions` | `ad_ecpm_record` | 每行 = 客户端一次广告展示。激励视频每次展示上报一条;**信息流轮播每条展示各上报一条**(每条独立 `ad_session_id`) |
| 收益 `revenue_yuan` | `ad_ecpm_record` | `Σ(eCPM元 ÷ 1000)`,即每条展示预估收益累加(eCPM 原值是分,÷100 转元;÷1000 是每千次→单次)。**预估口径,非结算;测试应用多为 0** |
| 应发/实发金币 `expected_coin`/`actual_coin` | `ad_reward_record`(reward_video)+ `ad_feed_reward_record`(feed) | **复用金币审计逐条复算**(`ad_audit.audit_rows`,与正式发奖同一公式口径,不另写公式),按同维度求和;`matched = 应发==实发`。**只读复算,不改发奖** |
| 来源应用/代码位 `app_env`/`our_code_id` | 上述各表回填 | `prod`(傻瓜比价)/`test`(测试);代码位是**我们后台配的 104xxx**,非底层 rit |
| 底层渠道 `adns` | `ad_ecpm_record` | 实际填充的 ADN 子渠道集合(pangle/gdt/...),附加参考 |
展示与金币来自不同表,做**并集**:有展示无金币(用户中途关、未达发奖)、有金币无展示(未上报 eCPM)各自成行。
## GET /admin/api/ad-revenue-report — 聚合报表
- 入参(均 query,可选):
| 参数 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `date_from` | string | 今天 | 起始日 北京时间 `YYYY-MM-DD` |
| `date_to` | string | =`date_from` | 结束日 北京时间 `YYYY-MM-DD`,**闭区间**;单日时与 `date_from` 相同 |
| `user_id` | int | 全部 | 只看某用户;不传=所有用户 |
| `ad_type` | string | 全部 | `reward_video` / `feed` / `draw`;不传=全部类型 |
| `granularity` | string | `day` | `day`=按天 / `hour`=按小时(聚合键再加北京时间小时 0–23);**区间>1 天建议用 day** |
| `limit` | int(1~1000) | 500 | **展示**明细组数(截断;`total`/`total_*`/`daily` 按全量统计不受影响) |
约束:`date_to` 不早于 `date_from`、区间最长 **92 天**、日期须 `YYYY-MM-DD`,否则 `422`
- 出参 `200`:`AdRevenueReportOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `date_from` / `date_to` | string | 报表起止日期(闭区间) |
| `daily` | `AdRevenueDaily[]` | 按日期汇总序列(全量,供按天趋势图;不受 `limit` 影响) |
| `total` | int | 聚合组**总数**(全量,不受 `limit` 影响) |
| `truncated` | bool | 明细是否被 `limit` 截断 |
| `total_impressions` | int | 全量展示条数合计 |
| `total_revenue_yuan` | float | 全量收益合计(元) |
| `total_expected_coin` | int | 全量应发金币合计 |
| `total_actual_coin` | int | 全量实发金币合计 |
| `mismatch_count` | int | 应发≠实发的组数(=0 说明全部按公式发放) |
| `items` | `AdRevenueRow[]` | 聚合明细(按 日期→用户→类型→代码位 排序) |
### AdRevenueDaily(`daily[]` — 按天趋势)
| 字段 | 类型 | 说明 |
|---|---|---|
| `date` | string | 北京时间 `YYYY-MM-DD` |
| `impressions` | int | 当天展示条数合计 |
| `revenue_yuan` | float | 当天预估收益合计(元) |
| `expected_coin` | int | 当天应发金币合计 |
| `actual_coin` | int | 当天实发金币合计 |
### AdRevenueRow(`items[]`)
| 字段 | 类型 | 说明 |
|---|---|---|
| `report_date` | string | 该组所属日期 北京时间 `YYYY-MM-DD` |
| `user_id` | int | |
| `ad_type` | string | `reward_video` / `feed` / `draw` |
| `app_env` | string \| null | 我们的应用:`prod`(傻瓜比价)/`test`(测试);旧数据为空 |
| `our_code_id` | string \| null | 我们配置的代码位 104xxx;旧数据为空 |
| `hour` | int \| null | 北京时间小时 023(`granularity=hour` 时有值;按天为 null) |
| `impressions` | int | 展示条数 |
| `revenue_yuan` | float | 收益(元),预估口径 |
| `expected_coin` | int | 应发金币(公式复算,与金币审计同源) |
| `actual_coin` | int | 实发金币(实际入账) |
| `matched` | bool | 该组应发==实发(组内任一条不符则 false) |
| `adns` | string[] | 底层填充 ADN 子渠道集合 |
| `impression_records` | `AdRevenueImpression[]` | 该组**逐条展示明细**(前端展开下钻);只要有展示就非空 |
| `records` | `AdRevenueRecord[]` | 该组**逐条发奖复算明细**(前端展开下钻);纯展示无发奖的组为空 |
### AdRevenueImpression(`items[].impression_records[]` — 展开「展示明细」)
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | ad_ecpm_record 主键 |
| `created_at` | datetime | |
| `ecpm` | string | 本次展示 eCPM 原始值(分/千次展示) |
| `revenue_yuan` | float | 本次展示预估收益(元)= eCPM元 ÷ 1000 |
| `adn` | string \| null | 实际填充 ADN 子渠道 |
| `slot_id` | string \| null | 底层 mediation rit(非我们配置的广告位 ID) |
### AdRevenueRecord(`items[].records[]` — 展开「发奖明细」)
还原金币审计的逐条列,与发奖同一复算口径。
| 字段 | 类型 | 说明 |
|---|---|---|
| `record_id` | int | 发奖记录主键 |
| `created_at` | datetime | |
| `status` | string | `granted` / `capped` / `ecpm_missing` |
| `ecpm` | string \| null | 本次采用的 eCPM 原始值 |
| `ecpm_factor` | float \| null | 因子1(eCPM 档);非 granted 为空 |
| `units` | int | 折算份数:激励视频恒 1;信息流 = 满 10 秒份数 |
| `lt_index_start` / `lt_index_end` | int \| null | 占用「账号累计第几份」的起止 |
| `lt_factor_start` / `lt_factor_end` | float \| null | 因子2(LT)起止值 |
| `expected_coin` | int | 应发金币 |
| `actual_coin` | int | 实发金币 |
| `matched` | bool | 该条复算与实发是否一致 |
## 说明与局限
- **展示 vs 发奖分离**:信息流轮播一会话可展示多条(都计入 `impressions`),但发奖仍按现规则(一会话发一次),`coin` 不因展示条数变化——这是有意设计(用户中途关只记展示不发奖)。
- **历史 Draw 不可拆**:迁移(Draw→普通信息流)前,Draw 发奖混在 `ad_feed_reward_record` 且无类型标记,金币侧统一记 `feed`;迁移后 Draw 不再产生新数据。展示侧 `ad_type` 由客户端上报区分,故 `draw` 桶基本为空。
- **来源字段从上线起齐全**:`app_env`/`our_code_id` 是本期新增列,历史记录为 NULL(报表来源列留空)。
- **收益是预估**:基于客户端上报的 eCPM,非穿山甲后台结算值;以后台报表为结算权威。
- **对账聚合级 + 逐条下钻**:行级 `matched` 给出该组(用户×类型×应用×代码位)应发是否==实发;**展开 `records` 即可看该组逐条明细**(eCPM/因子1/份数/LT/因子2/应发/实发/一致)定位到具体记录。独立逐条审计接口 [admin-ad-coin-audit](./admin-ad-coin-audit.md) 仍保留(同一复算口径,可全局按场景/只看不符筛选)。
+11 -5
View File
@@ -1,4 +1,4 @@
# GET /admin/api/feedbacks — 反馈工单列表(游标分页) # GET /admin/api/feedbacks — 反馈工单列表(offset 分页 + 筛选/排序)
> 所属:Admin·反馈 组(前缀 `/admin/api/feedbacks` | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 `require_role`,仅 `get_current_admin` | [← 返回 API 索引](./README.md) > 所属:Admin·反馈 组(前缀 `/admin/api/feedbacks` | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 `require_role`,仅 `get_current_admin` | [← 返回 API 索引](./README.md)
@@ -7,8 +7,13 @@
|---|---|---|---|---| |---|---|---|---|---|
| `status` | string | ❌ | null | 反馈状态,精确匹配:`new`(待处理) / `handled`(已处理);传空/不传则不筛 | | `status` | string | ❌ | null | 反馈状态,精确匹配:`new`(待处理) / `handled`(已处理);传空/不传则不筛 |
| `user_id` | int | ❌ | null | 按提交用户 id 精确筛 | | `user_id` | int | ❌ | null | 按提交用户 id 精确筛 |
| `content` | string | ❌ | null | 反馈内容模糊匹配(ilike,≤100 字) |
| `created_from` | datetime | ❌ | null | 提交时间 ≥(ISO,统一按 UTC 比较) |
| `created_to` | datetime | ❌ | null | 提交时间 ≤(ISO,统一按 UTC 比较) |
| `sort_by` | string | ❌ | `id` | 排序列:`id` / `created_at` |
| `sort_order` | string | ❌ | `desc` | `asc` / `desc` |
| `limit` | int | ❌ | 20 | 1100 | | `limit` | int | ❌ | 20 | 1100 |
| `cursor` | int | ❌ | null | 上一页 next_cursor(按 feedback id 倒序,查 `id < cursor`) | | `cursor` | int | ❌ | null | 上一页 next_cursor(**offset 分页**,cursor=offset) |
## 出参 ## 出参
响应 `200`:`{ items: FeedbackOut[], next_cursor: int|null }`(`next_cursor=null` 表示末页) 响应 `200`:`{ items: FeedbackOut[], next_cursor: int|null }`(`next_cursor=null` 表示末页)
@@ -26,9 +31,10 @@
## 错误码 ## 错误码
- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`) - `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`)
- `422` `limit` 超出 1–100 范围 / 字段类型不合法 - `422` `limit` 超出 1100 范围 / `sort_by`·`sort_order` 不在允许集 / 字段类型不合法
## 说明 ## 说明
- 游标分页约定:结果按 feedback `id` 倒序;`cursor` 传上一页返回的 `next_cursor`;`next_cursor=null` 即末页。 - **offset 分页**(同 [admin-users-list](./admin-users-list.md)):`cursor` 即 offset,传上一页返回的 `next_cursor`;`next_cursor=null` 即末页。改用 offset 是为了在任意列排序下游标语义统一,代价是翻页期间数据变动可能错位一条(admin 低频可接受)。
- `status` / `user_id` 均为精确匹配,可叠加 - 排序:`sort_by`(id/created_at)× `sort_order`(asc/desc),恒以 `id` 同向兜底次序
- `status` / `user_id` 精确匹配、`content` 模糊、`created_from`/`created_to` 时间范围,均可叠加。
- 关联表 [feedback](../database/feedback.md);截图为相对路径,经 `GET /media/feedback/<file>` 静态读。 - 关联表 [feedback](../database/feedback.md);截图为相对路径,经 `GET /media/feedback/<file>` 静态读。
+34
View File
@@ -0,0 +1,34 @@
# POST /admin/api/users/{user_id}/cash — 手动增减/设值现金(带审计)
> 所属:Admin·用户 组(前缀 `/admin/api/users` | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md)
主要用于给无现金用户直接发钱、好让其测试提现链路。
## 入参
- 路径:`user_id`(int)
- **application/json**:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `mode` | string | ✗ | `delta`(默认)=增减 / `set`=设为指定值 |
| `amount_cents` | int | ✓ | `delta` 模式:现金变动(分,正=发放,负=扣减,不可为 0);`set` 模式:目标现金值(分,须 ≥ 0) |
| `reason` | string | ✓ | 操作原因,1–128 字(必填,入审计与流水备注) |
## 出参
响应 `200`:`OkResponse` = `{ "ok": true }`
## 错误码
- `400`(`delta`)`amount_cents == 0`(`detail: "amount_cents 不能为 0"`);或负数扣减后余额会变负(`detail: "扣减后现金为负(当前余额 N 分)"`)
- `400`(`set`)目标值为负(`detail: "目标现金值不能为负"`);或目标值等于当前余额(`detail: "当前现金已为 N 分,无需调整"`)
- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`)
- `403` 角色不足(需 `finance``super_admin`)
- `404` 用户不存在(`detail: "用户不存在"`)
- `422``amount_cents`/`reason``reason` 长度不在 1128、`mode``delta`/`set`、字段类型不合法 / `user_id` 非整数
## 说明
- 金额单位一律为**分**(`*_cents`);本接口只动现金余额,不涉及金币。
- **set 模式**:读当前余额算出差值 `delta = target - 当前余额`,再复用同一套写入逻辑(故只写一笔差值流水)。目标值须 ≥ 0;差值为 0(已等于目标)直接拒绝。
- 扣减保护:实际写入的 `delta < 0` 时若扣减后现金余额 < 0 直接拒绝(运营误操作保护);set 模式目标值 ≥ 0 天然不会扣成负。
- 现金变动写流水 [cash_transaction](../database/cash_transaction.md):`biz_type` 实际差值为正记 `admin_grant`、为负记 `admin_deduct`(set 模式同理,不新增流水类型),`remark = admin:<reason>`(截断至 128 字)。
- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action = user.cash.grant`,`target_type = user`,`target_id = user_id`,`detail = {amount_cents(=实际差值), balance_after_cents, reason}`;set 模式额外带 `{mode:"set", target_cents, before_cents}`。并记录操作 IP。
- 现金变动 + 审计在同一事务原子提交(改钱必留痕)。
- 关联用户表 [user](../database/user.md);现金账户 [coin_account](../database/coin_account.md);现金流水 [cash_transaction](../database/cash_transaction.md)。
+10 -7
View File
@@ -1,4 +1,4 @@
# POST /admin/api/users/{user_id}/coins — 手动增减金币(带审计) # POST /admin/api/users/{user_id}/coins — 手动增减/设值金币(带审计)
> 所属:Admin·用户 组(前缀 `/admin/api/users` | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md) > 所属:Admin·用户 组(前缀 `/admin/api/users` | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md)
@@ -7,23 +7,26 @@
- **application/json**: - **application/json**:
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|---|---|---|---| |---|---|---|---|
| `amount` | int | | 金币变动(个数):正=增加,负=扣减;不可为 0 | | `mode` | string | | `delta`(默认)=增减 / `set`=设为指定值 |
| `amount` | int | ✓ | `delta` 模式:金币变动(正=增加,负=扣减,不可为 0);`set` 模式:目标金币值(须 ≥ 0) |
| `reason` | string | ✓ | 操作原因,1–128 字(必填,入审计与流水备注) | | `reason` | string | ✓ | 操作原因,1–128 字(必填,入审计与流水备注) |
## 出参 ## 出参
响应 `200`:`OkResponse` = `{ "ok": true }` 响应 `200`:`OkResponse` = `{ "ok": true }`
## 错误码 ## 错误码
- `400` `amount == 0`(`detail: "amount 不能为 0"`);或负数扣减后余额会变负(`detail: "扣减后金币为负(当前余额 N)"`) - `400`(`delta`)`amount == 0`(`detail: "amount 不能为 0"`);或负数扣减后余额会变负(`detail: "扣减后金币为负(当前余额 N)"`)
- `400`(`set`)目标值为负(`detail: "目标金币值不能为负"`);或目标值等于当前余额(`detail: "当前金币已为 N,无需调整"`)
- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`) - `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`)
- `403` 角色不足(需 `finance``super_admin`) - `403` 角色不足(需 `finance``super_admin`)
- `404` 用户不存在(`detail: "用户不存在"`) - `404` 用户不存在(`detail: "用户不存在"`)
- `422``amount`/`reason``reason` 长度不在 1–128、字段类型不合法 / `user_id` 非整数 - `422``amount`/`reason``reason` 长度不在 1128、`mode``delta`/`set`字段类型不合法 / `user_id` 非整数
## 说明 ## 说明
- 金币 = 个数(非现金);本接口只动金币余额,不涉及现金(`*_cents`)。 - 金币 = 个数(非现金);本接口只动金币余额,不涉及现金(`*_cents`)。
- 扣减保护:`amount < 0` 时若扣减后金币余额 < 0 直接拒绝(运营误操作保护) - **set 模式**:读当前余额算出差值 `delta = target - 当前余额`,再复用同一套写入逻辑(故只写一笔差值流水)。目标值须 ≥ 0;差值为 0(已等于目标)直接拒绝
- 金币变动写流水 [coin_transaction](../database/coin_transaction.md):`biz_type` 增加为 `admin_grant`、扣减为 `admin_deduct`,`remark = admin:<reason>`(截断至 128 字) - 扣减保护:实际写入的 `delta < 0` 时若扣减后金币余额 < 0 直接拒绝(运营误操作保护);set 模式目标值 ≥ 0 天然不会扣成负
- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action = user.coins.grant`,`target_type = user`,`target_id = user_id`,`detail = {amount, balance_after, reason}`,并记录操作 IP - 金币变动写流水 [coin_transaction](../database/coin_transaction.md):`biz_type` 实际差值为正记 `admin_grant`、为负记 `admin_deduct`(set 模式同理,不新增流水类型),`remark = admin:<reason>`(截断至 128 字)
- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action = user.coins.grant`,`target_type = user`,`target_id = user_id`,`detail = {amount(=实际差值), balance_after, reason}`;set 模式额外带 `{mode:"set", target, before}`。并记录操作 IP。
- 金币变动 + 审计在同一事务原子提交(改钱必留痕)。 - 金币变动 + 审计在同一事务原子提交(改钱必留痕)。
- 关联用户表 [user](../database/user.md);金币账户 [coin_account](../database/coin_account.md);金币流水 [coin_transaction](../database/coin_transaction.md)。 - 关联用户表 [user](../database/user.md);金币账户 [coin_account](../database/coin_account.md);金币流水 [coin_transaction](../database/coin_transaction.md)。
+11 -5
View File
@@ -1,4 +1,4 @@
# GET /admin/api/users — 用户列表(筛选+分页) # GET /admin/api/users — 用户列表(筛选+排序+分页)
> 所属:Admin·用户 组(前缀 `/admin/api/users` | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role | [← 返回 API 索引](./README.md) > 所属:Admin·用户 组(前缀 `/admin/api/users` | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role | [← 返回 API 索引](./README.md)
@@ -8,8 +8,13 @@
| `phone` | string | ❌ | null | 手机号**前缀**匹配(`phone LIKE '<值>%'`) | | `phone` | string | ❌ | null | 手机号**前缀**匹配(`phone LIKE '<值>%'`) |
| `register_channel` | string | ❌ | null | 注册渠道,精确匹配 | | `register_channel` | string | ❌ | null | 注册渠道,精确匹配 |
| `status` | string | ❌ | null | 用户状态,精确匹配:`active` / `disabled` / `deleted` | | `status` | string | ❌ | null | 用户状态,精确匹配:`active` / `disabled` / `deleted` |
| `nickname` | string | ❌ | null | 昵称**模糊**匹配(`ILIKE '%<值>%'`) |
| `created_from` / `created_to` | datetime | ❌ | null | 注册时间范围(ISO 8601;按 UTC 比较) |
| `last_login_from` / `last_login_to` | datetime | ❌ | null | 最近登录时间范围(ISO 8601;按 UTC 比较) |
| `sort_by` | string | ❌ | `id` | 排序列:`id` / `created_at` / `last_login_at` |
| `sort_order` | string | ❌ | `desc` | `asc` / `desc` |
| `limit` | int | ❌ | 20 | 1100 | | `limit` | int | ❌ | 20 | 1100 |
| `cursor` | int | ❌ | null | 上一页 next_cursor(按 user id 倒序,查 `id < cursor`) | | `cursor` | int | ❌ | null | 上一页 next_cursor(**offset 偏移量**) |
## 出参 ## 出参
响应 `200`:`{ items: AdminUserListItem[], next_cursor: int|null }`(`next_cursor=null` 表示末页) 响应 `200`:`{ items: AdminUserListItem[], next_cursor: int|null }`(`next_cursor=null` 表示末页)
@@ -28,10 +33,11 @@
## 错误码 ## 错误码
- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`) - `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`)
- `422` `limit` 超出 1–100 范围 / 字段类型不合法 - `422` `limit` 超出 1100 范围 / `sort_by`·`sort_order` 不在白名单 / 日期格式不合法 / 字段类型不合法
## 说明 ## 说明
- 游标分页约定:结果按 user `id` 倒序;`cursor` 传上一页返回的 `next_cursor`;`next_cursor=null` 即末页。 - **分页为 offset 偏移式**(为支持任意列排序):`cursor` 传上一页返回的 `next_cursor`(实为 offset);`next_cursor=null` 即末页。代价:翻页期间数据变动可能错位一条,admin 低频场景可接受(同提现列表)。
- `phone` 为前缀匹配(`LIKE '<值>%'`),`register_channel` / `status` 为精确匹配;三者可叠加 - 排序:`sort_by``id`/`created_at`/`last_login_at`,非法值回落 `id`;同值再按 `id` 同向兜底,次序稳定
- 筛选:`phone` 前缀、`nickname` 模糊(`ILIKE`)、`register_channel`/`status` 精确、`created_*`/`last_login_*` 时间范围;均可叠加。
- 关联用户表 [user](../database/user.md)。 - 关联用户表 [user](../database/user.md)。
- 历史明细(金币流水、提现、比价、反馈等)不在本列表,走各自带 `user_id` 过滤的分页接口。 - 历史明细(金币流水、提现、比价、反馈等)不在本列表,走各自带 `user_id` 过滤的分页接口。
+1 -1
View File
@@ -25,7 +25,7 @@
| `biz_type` | string | 业务类型 | | `biz_type` | string | 业务类型 |
| `ref_id` | string \| null | 关联业务 ID | | `ref_id` | string \| null | 关联业务 ID |
| `remark` | string \| null | 备注 | | `remark` | string \| null | 备注 |
| `created_at` | datetime | 创建时间(ISO 8601 UTC | | `created_at` | datetime | 创建时间(北京 wall-clock naive,非 UTC;前端按字面显示,不做时区换算 |
## 错误码 ## 错误码
- `401` 未带/无效/过期 admin token、管理员被禁用(响应头带 `WWW-Authenticate: Bearer` - `401` 未带/无效/过期 admin token、管理员被禁用(响应头带 `WWW-Authenticate: Bearer`
+1 -1
View File
@@ -25,7 +25,7 @@
| `biz_type` | string | 业务类型 | | `biz_type` | string | 业务类型 |
| `ref_id` | string \| null | 关联业务 ID | | `ref_id` | string \| null | 关联业务 ID |
| `remark` | string \| null | 备注 | | `remark` | string \| null | 备注 |
| `created_at` | datetime | 创建时间(ISO 8601 UTC | | `created_at` | datetime | 创建时间(北京 wall-clock naive,非 UTC;前端按字面显示,不做时区换算 |
## 错误码 ## 错误码
- `401` 未带/无效/过期 admin token、管理员被禁用(响应头带 `WWW-Authenticate: Bearer` - `401` 未带/无效/过期 admin token、管理员被禁用(响应头带 `WWW-Authenticate: Bearer`
+5 -5
View File
@@ -6,9 +6,9 @@
**multipart/form-data**: **multipart/form-data**:
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|---|---|---|---| |---|---|---|---|
| `content` | string | ✓ | 反馈正文,**1-2000 字**(strip 后) | | `content` | string | ✓ | 反馈正文,**1-200 字**(strip 后) |
| `contact` | string | | 联系方式(微信/QQ/手机号),**1-128 字**,便于回访 | | `contact` | string | | 联系方式(微信/QQ/手机号),**128 字**。原型改版后客户端已不再采集、不传该字段(后端默认空串);保留字段兼容旧端 |
| `images` | file[] | ✗ | 截图,**最多 4 张**,每张走头像同款校验(JPEG/PNG/WebP,≤ 5 MB,魔数嗅探) | | `images` | file[] | ✗ | 截图,**最多 6 张**,每张走头像同款校验(JPEG/PNG/WebP,≤ 5 MB,魔数嗅探) |
## 出参 ## 出参
响应 `200`: 响应 `200`:
@@ -29,9 +29,9 @@
> 不返回上传的 image URL——这是给运营后台看的,客户端通常不需要。 > 不返回上传的 image URL——这是给运营后台看的,客户端通常不需要。
## 错误码 ## 错误码
- `400` 内容为空 / 内容超 2000 字 / 联系方式为空 / 联系方式超 128 字 / 图片超 4 张 / 单图非法(空/过大/格式不对) - `400` 内容为空 / 内容超 200 字 / 联系方式超 128 字 / 图片超 6 张 / 单图非法(空/过大/格式不对)
- `401` 未带 token / token 无效或过期 / 用户被禁用 - `401` 未带 token / token 无效或过期 / 用户被禁用
- `422``content``contact` 字段 - `422``content` 字段
## 说明 ## 说明
- **反馈绑用户**:`feedback.user_id = current_user.id`,便于回访 - **反馈绑用户**:`feedback.user_id = current_user.id`,便于回访
+8 -4
View File
@@ -2,7 +2,11 @@
> 所属:Platform 组(前缀 `/api/v1/platform` | 鉴权:**无** | [← 返回 API 索引](./README.md) > 所属:Platform 组(前缀 `/api/v1/platform` | 鉴权:**无** | [← 返回 API 索引](./README.md)
客户端首页顶部「用户****xxx 比价后节省 xx 元」滚动条数据源。全平台真实比价记录优先;不足时用运营配的种子([ops_marquee_seed](../database/ops_marquee_seed.md))补齐到 `limit` 条「混播」,保证轮播不空。 客户端首页顶部「用户 比价后节省 xx 元」滚动条数据源。全平台真实比价记录优先;不足时用运营配的种子([ops_marquee_seed](../database/ops_marquee_seed.md))补齐到 `limit` 条「混播」,保证轮播不空。
**用户标识脱敏规则(全 feed 统一)**:① 有昵称 → 昵称脱敏:≥3 字「首+隐藏字数个星+末」(省钱小能手→`省***手`)、2 字「首+星」(阿强→`阿*`)、1 字「用户+该字」(喵→`用户喵`);② 没昵称 → 按 `user_id` **确定性合成一个假昵称再脱敏**(同用户恒定,避免无昵称用户清一色 `用户****`),少数露「用户」+ 8 星 + id 后 3 位(`用户********618`)。
> 合成假名走**本地语料组合生成**(无外部库):中文真名(姓池 ×名字字池 拼「姓+1~2字」) / 中文网络昵称 / 英文昵称 / 英文名(可带数字尾) 混播,中文为主英文为辅,组合空间上万。真实条按 `user_id` 播种 → 同一用户名字恒定、不同用户各异(刷新/翻页不变脸,同屏几乎不撞名);种子/兜底用运行时随机源出多样。当前真实用户大多没设昵称,故走合成。
## 入参 ## 入参
| 参数 | 类型 | 说明 | | 参数 | 类型 | 说明 |
@@ -15,15 +19,15 @@
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|---|---|---| |---|---|---|
| `items` | list | 轮播条目数组(最多 `limit` 条) | | `items` | list | 轮播条目数组(最多 `limit` 条) |
| `items[].masked_user` | string | 脱敏用户名,手机尾号(`138****5678`)/ 中文昵称(`省钱**`)混合风格 | | `items[].masked_user` | string | 脱敏用户名:昵称/合成名脱敏(`省***手` / `王*` / `SaveK**g`)或匿名 `用户********618` |
| `items[].saved_amount_cents` | int | 节省(分),客户端 ÷100 显示「x.xx 元」 | | `items[].saved_amount_cents` | int | 节省(分),客户端 ÷100 显示「x.xx 元」 |
| `items[].time` | string | 北京时间 `HH:MM:SS` | | `items[].time` | string | 北京时间 `HH:MM:SS` |
## 说明 ## 说明
- 真实条:`comparison_record``status='success'``0 < saved_amount_cents ≤ 300 元` 的近期记录(金额超 300 元视为异常 / bug 值剔除,防「节省 999 元」穿帮),**按 `user_id` 去重**(同一用户只取最新一条,避免单人刷屏);用户名`user_id` **确定性合成**手机尾号 / 中文昵称混合脱敏名(同用户恒定)。 - 真实条:`comparison_record``status='success'``0 < saved_amount_cents ≤ 300 元` 的近期记录(金额超 300 元视为异常 / bug 值剔除,防「节省 999 元」穿帮),**JOIN `user` 表取昵称 → 仅 user 表内用户的记录入选**(孤儿/已删用户记录不计,设计如此;当前用户量少,缺口由种子补足),**`user_id` 去重**(同一用户只取最新一条,避免单人刷屏);有昵称按真实昵称脱敏,无昵称`user_id` 播种**确定性合成**脱敏名(同用户恒定、刷新不变脸)。
- 种子条:`ops_marquee_seed`(`enabled=true`),仅在真实去重后不足 `limit` 时补齐;**从启用种子中公平随机抽取**(不再固定取前 N,所有种子都有机会露出)。每条种子:用户名留空或旧 `用户****xxx` 模板名则**随机合成混合风格名并避开同屏撞名**(自愈历史种子),金额在 `[min_cents, max_cents]` 取**长尾随机值**(小额居多、偶尔大额,固定金额则 min==max)。 - 种子条:`ops_marquee_seed`(`enabled=true`),仅在真实去重后不足 `limit` 时补齐;**从启用种子中公平随机抽取**(不再固定取前 N,所有种子都有机会露出)。每条种子:用户名留空或旧 `用户****xxx` 模板名则**随机合成混合风格名并避开同屏撞名**(自愈历史种子),金额在 `[min_cents, max_cents]` 取**长尾随机值**(小额居多、偶尔大额,固定金额则 min==max)。
- 兜底:真实 + 种子仍凑不满 `limit`(种子被全停用 / 太少)时,用**内置合成条补满**,保证轮播既不空也不稀疏。 - 兜底:真实 + 种子仍凑不满 `limit`(种子被全停用 / 太少)时,用**内置合成条补满**,保证轮播既不空也不稀疏。
- **`time` 为合成的「最近」时间**(从当前北京时间往前**随机抖动**递减,首条几十秒前,其余多数 1~5 分钟、偶尔扎堆或较长):社会证明轮播保证永远像刚发生且节奏自然不机械,不受旧测试数据 / 低谷期记录影响。真实的用户/金额不变,只换展示时间。 - **`time` 为合成的「最近」时间**(从当前北京时间往前**随机抖动**递减,首条几十秒前,其余多数 1~5 分钟、偶尔扎堆或较长):社会证明轮播保证永远像刚发生且节奏自然不机械,不受旧测试数据 / 低谷期记录影响。真实的用户/金额不变,只换展示时间。
- 因含随机(抽取 / 金额 / 合成名),**每次请求结果都不同**——这是轮播想要的鲜活感。 - 因含随机(种子抽取 / 金额 / 展示时间 / 种子合成名),**每次请求结果都不同**——这是轮播想要的鲜活感;但**真实用户的脱敏名按 `user_id` 恒定**,同一用户跨请求不变脸(避免「同一人换张脸」露馅)
- **性能**:首页人人都看、真实数据变化慢,真实记录原始行带 **~30s 进程内缓存**(展示层随机仍每次重算,只省 DB 往返;新记录最多晚 30s 进轮播)。查询走复合索引 `ix_comparison_status_created (status, created_at)`,避免随数据量增大全表扫。 - **性能**:首页人人都看、真实数据变化慢,真实记录原始行带 **~30s 进程内缓存**(展示层随机仍每次重算,只省 DB 往返;新记录最多晚 30s 进轮播)。查询走复合索引 `ix_comparison_status_created (status, created_at)`,避免随数据量增大全表扫。
- 逻辑见 `app/repositories/ops_marquee.py`;运营管理种子见 [admin-marquee-seeds](./admin-marquee-seeds.md)。 - 逻辑见 `app/repositories/ops_marquee.py`;运营管理种子见 [admin-marquee-seeds](./admin-marquee-seeds.md)。
+19 -1
View File
@@ -2,7 +2,7 @@
> 跨表视角。单表字段级细节看同目录 `<表名>.md`(索引见 [README](./README.md))。 > 跨表视角。单表字段级细节看同目录 `<表名>.md`(索引见 [README](./README.md))。
> 本文专门回答三件「跨表」的事:**① 每块 App 功能用到哪些表 ② 什么操作往哪张表写 ③ 表和表怎么连(join key,含没有外键约束、靠业务字段对齐的语义关联)**。 > 本文专门回答三件「跨表」的事:**① 每块 App 功能用到哪些表 ② 什么操作往哪张表写 ③ 表和表怎么连(join key,含没有外键约束、靠业务字段对齐的语义关联)**。
> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **23 张业务表** + `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 兜底 | | profile「累计省了 / 省钱战绩 / 省钱明细」 | [`savings_record`](./savings_record.md) | 真实下单归因(source=compare)+ 无真实数据时 demo 兜底 |
| 「上报更低价」提交 / 列表 | [`price_report`](./price_report.md) | 众包纠偏:用户举证某平台更便宜,人工审核发奖 | | 「上报更低价」提交 / 列表 | [`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 位置 / 动作 | 表 | 说明 | | App 位置 / 动作 | 表 | 说明 |
|---|---|---| |---|---|---|
@@ -76,6 +84,13 @@
| 首次进 profile 省钱页且无真实记录 | `savings_record`(C `source=demo`) | 懒种子,`ensure_seeded` 按 user 幂等 | | 首次进 profile 省钱页且无真实记录 | `savings_record`(C `source=demo`) | 懒种子,`ensure_seeded` 按 user 幂等 |
| 上报更低价 `POST /report` | `price_report`(C) | 读 `comparison_record.best_price_cents` 校验 | | 上报更低价 `POST /report` | `price_report`(C) | 读 `comparison_record.best_price_cents` 校验 |
| 提交反馈 `POST /feedback` | `feedback`(C) | | | 提交反馈 `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`) ### admin 端(管理员触发,均额外写一条 `admin_audit_log`)
| 后台操作 | 写入 | 操作 | | 后台操作 | 写入 | 操作 |
@@ -122,6 +137,7 @@
- **`comparison_record.store_name``savings_record.shop_name`**:无 id 关联,按**店名字符串相等**给比价记录打「已下单」标记(瞬态,不写库)。两边店名同源 = 比价意图识别阶段的门店 query,语义=**店级**(同店比价多次会一并标已下单)。 - **`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` 仍是旧版兼容统计,不逐条参与发奖。 - **广告流会话关联**:`ad_reward_record.ad_session_id` 可与 `ad_ecpm_record.ad_session_id` 对齐;`ad_watch_log` 仍是旧版兼容统计,不逐条参与发奖。
- **里程碑解锁进度不存库**:`comparison_milestone_claim` 只记「哪几档已领」;进度 = `comparison_record``status='success'``count` - **里程碑解锁进度不存库**:`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`)。登录读、走完引导写,决定是否再展示新手引导。 - **`onboarding_completion.(user_id, device_id)`**:`user_id` 语义关联 `user.id`(无硬 FK,同 `coupon_*` 设备表),`device_id` = 客户端硬件级 `ANDROID_ID`(≠ 领券 per-install `device_id`)。登录读、走完引导写,决定是否再展示新手引导。
### ER 关系(文字版) ### ER 关系(文字版)
@@ -136,6 +152,8 @@ user ─1:N─ onboarding_completion (user_id, 无硬 FK; (user_id,d
comparison_record ─1:N─ price_report (comparison_record_id, 可空) comparison_record ─1:N─ price_report (comparison_record_id, 可空)
admin_user ─1:N─ admin_audit_log admin_user ─1:N─ admin_audit_log
app_config (独立, 无外键, key 为主键) app_config (独立, 无外键, key 为主键)
coupon_prompt_engagement / coupon_daily_completion / coupon_claim_record
(独立, 无硬 FK; 维度=device_id+北京日, user_id/trace_id 仅软关联)
``` ```
--- ---
+9 -5
View File
@@ -3,16 +3,13 @@
> 数据库:SQLite 起步(`data/app.db`),生产可切 PostgreSQL(改 `DATABASE_URL`)。 > 数据库:SQLite 起步(`data/app.db`),生产可切 PostgreSQL(改 `DATABASE_URL`)。
> ORM:SQLAlchemy 2.0(`app/models/`),迁移:Alembic(`alembic/versions/`,`render_as_batch` 兼容 SQLite)。 > ORM:SQLAlchemy 2.0(`app/models/`),迁移:Alembic(`alembic/versions/`,`render_as_batch` 兼容 SQLite)。
> 金额字段一律存**整数**:金币=个数,现金=**分**(`*_cents`)。时间列 `DateTime(timezone=True)` > 金额字段一律存**整数**:金币=个数,现金=**分**(`*_cents`)。时间列 `DateTime(timezone=True)`
> 最后更新:2026-06-11(合并:新增 3 张领券今日状态表 `coupon_*` + `onboarding_completion` 新手引导完成表;含 [OVERVIEW 总览](./OVERVIEW.md))
> 最后更新:2026-06-10(新增 `onboarding_completion` 新手引导完成表 → 23 张业务表)
=======
> 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。 > 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。
--- ---
## 表总览(23 张业务表 + `alembic_version` 框架表) ## 表总览(28 张业务表 + `alembic_version` 框架表)
### 账号 / 反馈 ### 账号 / 反馈
| 表 | 用途 | 模型 | 文档 | | 表 | 用途 | 模型 | 文档 |
@@ -45,6 +42,13 @@
| `savings_record` | 省钱记录(profile 省钱战绩源;真实下单归因 + demo) | `models/savings.py` | [详情](./savings_record.md) | | `savings_record` | 省钱记录(profile 省钱战绩源;真实下单归因 + demo) | `models/savings.py` | [详情](./savings_record.md) |
| `price_report` | 上报更低价(众包纠偏,人工审核发奖) | `models/price_report.py` | [详情](./price_report.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 券缓存 ### 美团 CPS 券缓存
| 表 | 用途 | 模型 | 文档 | | 表 | 用途 | 模型 | 文档 |
|---|---|---|---| |---|---|---|---|
+6 -4
View File
@@ -7,17 +7,19 @@
## 用在哪 / 增删改查 ## 用在哪 / 增删改查
- **C(插入)**:`POST /ad/ecpm-report`(`create_ecpm_record`)。每次广告展示上报一条;best-effort,丢一两条不影响业务。鉴权接口已确保 user 存在。 - **C(插入)**:`POST /ad/ecpm-report`(`create_ecpm_record`)。每次广告展示上报一条;best-effort,丢一两条不影响业务。鉴权接口已确保 user 存在。
- **U / D**:无。 - **U / D**:无。
- **R**:内部收益统计/对账(按 `(user_id, report_date)` 聚合);`count_today` 排查辅助。当前无面向 C 端用户的读接口。 - **R**:内部收益统计/对账(按 `(user_id, report_date)` 聚合);`count_today` 排查辅助;广告收益报表 [admin-ad-revenue-report](../api/admin-ad-revenue-report.md) 的展示条数/收益数据源(按 `app_env`/`our_code_id` 聚合)。当前无面向 C 端用户的读接口。
## 字段 ## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---| |---|---|---|---|
| `id` | Integer | PK, autoincrement | | | `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 | | `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `ad_type` | String(32) | NOT NULL | 广告类型,取值如 `reward_video`(激励视频)/ `draw`(Draw 信息流);各类型各自上报,不强行统一代码位 | | `ad_type` | String(32) | NOT NULL | 广告类型,取值如 `reward_video`(激励视频)/ `feed`(信息流)/ `draw`(历史 Draw 信息流);各类型各自上报,不强行统一代码位 |
| `ad_session_id` | String(64) | UNIQUE, index, nullable | 客户端广告会话 ID;用于普通激励视频 S2S 缺 `ecpm` 时匹配发奖 | | `ad_session_id` | String(64) | UNIQUE, index, nullable | 客户端广告会话 ID;激励视频用于 S2S 缺 `ecpm` 时匹配发奖。**信息流轮播每条展示用各自独立 id**(不复用比价会话 id,否则 UNIQUE 去重只留一条) |
| `adn` | String(32) | nullable | 实际投放 ADN(`getShowEcpm().getSdkName()`,如 `pangle`/`gdt`) | | `adn` | String(32) | nullable | 实际投放 ADN(`getShowEcpm().getSdkName()`,如 `pangle`/`gdt`;聚合后实际填充的子渠道) |
| `slot_id` | String(64) | nullable | 实际展示用代码位(底层 mediation rit,非客户端配置位) | | `slot_id` | String(64) | nullable | 实际展示用代码位(底层 mediation rit,非客户端配置位) |
| `app_env` | String(16) | nullable | **我们的**穿山甲应用环境:`prod`(傻瓜比价正式)/`test`(测试应用);旧数据 NULL。广告收益报表按它聚合「来源应用」 |
| `our_code_id` | String(64) | nullable | **我们后台配置的**代码位 ID(`AdConfig` 的 104xxx,**非** `slot_id` 底层 rit);旧数据 NULL |
| `ecpm_raw` | String(32) | NOT NULL | 客户端上报的 eCPM **原始串**(穿山甲 getEcpm 原值,单位**分/千次展示**);后端 ÷100 转元参与金币公式 | | `ecpm_raw` | String(32) | NOT NULL | 客户端上报的 eCPM **原始串**(穿山甲 getEcpm 原值,单位**分/千次展示**);后端 ÷100 转元参与金币公式 |
| `report_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它做按天聚合 | | `report_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它做按天聚合 |
| `created_at` | DateTime(tz) | server_default now(), index | 时间 | | `created_at` | DateTime(tz) | server_default now(), index | 时间 |
+7 -5
View File
@@ -11,13 +11,15 @@
| `ad_session_id` | String(64) | index, nullable | 客户端生成的一次信息流广告会话 id | | `ad_session_id` | String(64) | index, nullable | 客户端生成的一次信息流广告会话 id |
| `user_id` | Integer | FK → `user.id`, index, NOT NULL | 用户 | | `user_id` | Integer | FK → `user.id`, index, NOT NULL | 用户 |
| `reward_date` | String(10) | index, NOT NULL | 北京时间日期 `YYYY-MM-DD` | | `reward_date` | String(10) | index, NOT NULL | 北京时间日期 `YYYY-MM-DD` |
| `duration_seconds` | Integer | NOT NULL | 实际展示/播放秒数 | | `duration_seconds` | Integer | NOT NULL | 整场比价累计观看秒数(轮播各条相加) |
| `unit_count` | Integer | NOT NULL | `duration_seconds // 10` 得到的奖励份数 | | `unit_count` | Integer | NOT NULL | `duration_seconds // 10` 得到的奖励份数 |
| `ecpm_raw` | String(32) | NOT NULL | 客户端上报 eCPM 原始值 | | `ecpm_raw` | String(32) | NOT NULL | 客户端上报 eCPM 原始值 |
| `adn` | String(32) | nullable | 实际投放 ADN | | `adn` | String(32) | nullable | 实际投放 ADN(聚合后实际填充子渠道) |
| `slot_id` | String(64) | nullable | 实际展示代码位 | | `slot_id` | String(64) | nullable | 实际展示代码位(底层 mediation rit) |
| `coin` | Integer | NOT NULL | 实发金币,`capped` 时为 0 | | `app_env` | String(16) | nullable | 来源应用 `prod`(傻瓜比价)/`test`(测试),客户端 feed-reward 上报;旧数据 NULL。广告收益报表金币侧按它聚合 |
| `status` | String(16) | NOT NULL | `granted` / `capped` | | `our_code_id` | String(64) | nullable | 我们配置的代码位 104xxx,客户端上报;旧数据 NULL |
| `coin` | Integer | NOT NULL | 实发金币,非 `granted` 时为 0 |
| `status` | String(16) | NOT NULL | `granted`(已发) / `capped`(当日次数超限) / `too_short`(整场总时长<10s 凑不满一份) / `closed_early`(用户中途 ✕ 关闭,全程未看完) |
| `created_at` | DateTime | index, NOT NULL | 创建时间 | | `created_at` | DateTime | index, NOT NULL | 创建时间 |
## 约束 ## 约束
+6 -4
View File
@@ -5,7 +5,7 @@
每条 = 穿山甲一次**服务端激励回调**。`trans_id` 唯一做幂等键(穿山甲会重试,同号只处理一次)。`reward_scene` 区分普通激励视频、签到膨胀等场景;`reward_date`(北京时间日期串)给普通激励视频"每日上限"计数用。 每条 = 穿山甲一次**服务端激励回调**。`trans_id` 唯一做幂等键(穿山甲会重试,同号只处理一次)。`reward_scene` 区分普通激励视频、签到膨胀等场景;`reward_date`(北京时间日期串)给普通激励视频"每日上限"计数用。
## 用在哪 / 增删改查 ## 用在哪 / 增删改查
- **C(插入)**:`POST /ad/pangle-callback`(穿山甲 S2S,经 SHA256 验签;`grant_ad_reward` 或场景业务处理)或 `POST /ad/test-grant`(本地联调)。普通激励视频三道闸:① 验签不过 → API 层 403,不进库;② `trans_id` 已存在 → 原样返回不重复发;③ **当日发奖次数(`DAILY_AD_REWARD_LIMIT`,默认 500)到顶** → 记一行 `status='capped'``coin=0`、不发币。否则按 eCPM 公式发币。 - **C(插入)**:`POST /ad/pangle-callback`(穿山甲 S2S,经 SHA256 验签;`grant_ad_reward` 或场景业务处理)或 `POST /ad/test-grant`(本地联调)。普通激励视频三道闸:① 验签不过 → API 层 403,不进库;② `trans_id` 已存在 → 原样返回不重复发;③ **当日发奖次数(`DAILY_AD_REWARD_LIMIT`,默认 500)到顶** → 记一行 `status='capped'``coin=0`、不发币。否则按 eCPM 公式发币。另:`POST /ad/reward-noshow`(`record_reward_noshow`,Bearer)在用户提前关/未发奖时记一行 `status='closed_early'``coin=0` 留痕(同 session 已 granted 则跳过)。
- **U / D**:无。 - **U / D**:无。
- **R**:`GET /ad/reward-status`(看广告页:今日已发次数/上限、单次金币、本轮已看/冷却结束、今日已看时长/上限);审计/对账整表回溯。 - **R**:`GET /ad/reward-status`(看广告页:今日已发次数/上限、单次金币、本轮已看/冷却结束、今日已看时长/上限);审计/对账整表回溯。
@@ -13,13 +13,15 @@
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---| |---|---|---|---|
| `id` | Integer | PK, autoincrement | | | `id` | Integer | PK, autoincrement | |
| `trans_id` | String(64) | UNIQUE, index, NOT NULL | 穿山甲交易号(幂等键)。**被 `coin_transaction.ref_id` 引用**(biz_type=reward_video/signin_boost 等) | | `trans_id` | String(64) | UNIQUE, index, NOT NULL | 穿山甲交易号(幂等键)。**被 `coin_transaction.ref_id` 引用**(biz_type=reward_video/signin_boost 等)`closed_early` 留痕记录无 S2S 交易号,用合成键 `noreward:{ad_session_id}` |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户(回调 media_extra 带回;不存在抛 UnknownUserError) | | `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户(回调 media_extra 带回;不存在抛 UnknownUserError) |
| `reward_scene` | String(32) | NOT NULL, default `reward_video` | 奖励场景:`reward_video` 普通激励视频;`signin_boost` 签到膨胀 | | `reward_scene` | String(32) | NOT NULL, default `reward_video` | 奖励场景:`reward_video` 普通激励视频;`signin_boost` 签到膨胀 |
| `ad_session_id` | String(64) | index, nullable | 客户端广告会话 ID,来自 `extra.ad_session_id`;用于匹配 `ad_ecpm_record` | | `ad_session_id` | String(64) | index, nullable | 客户端广告会话 ID,来自 `extra.ad_session_id`;用于匹配 `ad_ecpm_record` |
| `ecpm_raw` | String(32) | nullable | 本次发奖采用的 eCPM 原始值;可来自 S2S `ecpm` 或客户端上报 | | `ecpm_raw` | String(32) | nullable | 本次发奖采用的 eCPM 原始值;可来自 S2S `ecpm` 或客户端上报 |
| `coin` | Integer | NOT NULL, default 0 | 实发金币;`capped`/`ecpm_missing`/业务不满足时为 0 | | `app_env` | String(16) | nullable | 来源应用 `prod`(傻瓜比价)/`test`(测试);S2S 不带,发奖时按 `ad_session_id` 匹配 `ad_ecpm_record` 回填,查不到 NULL。广告收益报表金币侧按它聚合 |
| `status` | String(16) | NOT NULL, default `granted` | 取值:`granted`(已发)/ `capped`(当日次数超限)/ `ecpm_missing`(缺 eCPM)/ `not_signed`/`already_boosted`/`last_day` | | `our_code_id` | String(64) | nullable | 我们配置的代码位 104xxx(同上回填) |
| `coin` | Integer | NOT NULL, default 0 | 实发金币;`capped`/`ecpm_missing`/`closed_early`/业务不满足时为 0 |
| `status` | String(16) | NOT NULL, default `granted` | 取值:`granted`(已发)/ `capped`(当日次数超限)/ `ecpm_missing`(缺 eCPM)/ `closed_early`(展示了但用户提前关/跳过,未发奖,客户端 reward-noshow 留痕)/ `not_signed`/`already_boosted`/`last_day` |
| `reward_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它等值统计当日发奖次数 | | `reward_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它等值统计当日发奖次数 |
| `reward_name` | String(64) | nullable | 穿山甲上报奖励名(参考,不作发奖依据) | | `reward_name` | String(64) | nullable | 穿山甲上报奖励名(参考,不作发奖依据) |
| `raw` | String(1024) | nullable | 回调原始参数(审计排查) | | `raw` | String(1024) | nullable | 回调原始参数(审计排查) |
+123
View File
@@ -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` | JSONPG 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))。
+3 -3
View File
@@ -2,10 +2,10 @@
> 模型 `app/models/feedback.py` · 仓库 `app/repositories/feedback.py` · 接口 [feedback](../api/feedback.md) · admin [admin-feedbacks-list](../api/admin-feedbacks-list.md) / [admin-feedback-handle](../api/admin-feedback-handle.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) > 模型 `app/models/feedback.py` · 仓库 `app/repositories/feedback.py` · 接口 [feedback](../api/feedback.md) · admin [admin-feedbacks-list](../api/admin-feedbacks-list.md) / [admin-feedback-handle](../api/admin-feedback-handle.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
App「帮助与反馈」每次提交写一行。`content` `contact` 必填,`images` 为可选截图。后台人工处理后置 `handled`。与 `price_report`(结构化上报更低价)不同,本表是**自由文本**反馈。 App「帮助与反馈」每次提交写一行。`content` 必填;`contact` 必填,**原型改版后客户端不再采集,新数据存空串**(列保持 NOT NULL、免迁移,历史数据仍有值);`images` 为可选截图(≤6 张)。后台人工处理后置 `handled`。与 `price_report`(结构化上报更低价)不同,本表是**自由文本**反馈。
## 用在哪 / 增删改查 ## 用在哪 / 增删改查
- **C(插入)**:`POST /api/v1/feedback`(multipart:`content` + `contact` + 可选 `images`;`create_feedback`)。截图先经 `core.media``/media/feedback/` 拿相对路径,再随反馈写入,`status='new'` - **C(插入)**:`POST /api/v1/feedback`(multipart:`content` + 可选 `contact` + 可选 `images`;`create_feedback`)。截图先经 `core.media``/media/feedback/` 拿相对路径,再随反馈写入,`status='new'`
- **U(更新)**:admin 处理反馈 `update_feedback_status``status='handled'`(同事务写 `admin_audit_log`)。 - **U(更新)**:admin 处理反馈 `update_feedback_status``status='handled'`(同事务写 `admin_audit_log`)。
- **D**:无。 - **D**:无。
- **R**:admin 反馈列表(可按 `status` 筛)。C 端当前无"我的反馈列表"读接口。 - **R**:admin 反馈列表(可按 `status` 筛)。C 端当前无"我的反馈列表"读接口。
@@ -16,7 +16,7 @@ App「帮助与反馈」每次提交写一行。`content` 与 `contact` 必填,`
| `id` | Integer | PK, autoincrement | | | `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 提交用户 | | `user_id` | Integer | FK→user.id, index, NOT NULL | 提交用户 |
| `content` | Text | NOT NULL | 反馈正文 | | `content` | Text | NOT NULL | 反馈正文 |
| `contact` | String(128) | NOT NULL | 联系方式(微信/QQ/手机,便于回访) | | `contact` | String(128) | NOT NULL | 联系方式(微信/QQ/手机)。客户端改版后不再采集,新数据为空串;列仍 NOT NULL |
| `images` | JSON | nullable | 截图相对 URL 列表 `/media/feedback/...`;无图为 NULL | | `images` | JSON | nullable | 截图相对 URL 列表 `/media/feedback/...`;无图为 NULL |
| `status` | String(16) | NOT NULL, default `new` | 取值:`new`(待处理)/ `handled`(已处理) | | `status` | String(16) | NOT NULL, default `new` | 取值:`new`(待处理)/ `handled`(已处理) |
| `created_at` | DateTime(tz) | server_default now(), index | 提交时间 | | `created_at` | DateTime(tz) | server_default now(), index | 提交时间 |
+1 -1
View File
@@ -8,7 +8,7 @@
| 列 | 类型 | 约束 / 默认 | 说明 | | 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---| |---|---|---|---|
| `id` | Integer | PK, autoincrement | | | `id` | Integer | PK, autoincrement | |
| `masked_user` | String(64) | NULL 可空 | 脱敏用户名,手机尾号(`138****5678`)/ 中文昵称(`省钱**`)风格(整串存)。**留空或旧 `用户****xxx` 模板名 → feed 展示时随机合成混合风格名**(避开同屏撞名、自愈历史种子),风格与真实条一致、永不穿帮 | | `masked_user` | String(64) | NULL 可空 | 脱敏用户名(整串存)。**留空或旧 `用户****xxx` 模板名 → feed 展示时按脱敏规则随机合成**(本地姓/名字池组合的真名 + 中英网络昵称语料,首末字+星;避开同屏撞名、自愈历史种子),风格与真实条一致、永不穿帮。详见 [platform-savings-feed](../api/platform-savings-feed.md) |
| `min_cents` | Integer | NOT NULL | 节省金额区间下限(分) | | `min_cents` | Integer | NOT NULL | 节省金额区间下限(分) |
| `max_cents` | Integer | NOT NULL | 节省金额区间上限(分);feed 每次在 `[min,max]` 随机取值,**固定金额则 min==max** | | `max_cents` | Integer | NOT NULL | 节省金额区间上限(分);feed 每次在 `[min,max]` 随机取值,**固定金额则 min==max** |
| `enabled` | Boolean | NOT NULL, default true | 停用的不参与混播 | | `enabled` | Boolean | NOT NULL, default true | 停用的不参与混播 |
+4 -3
View File
@@ -125,7 +125,7 @@ def test_long_password_does_not_crash(admin_client: TestClient) -> None:
def test_audit_log_pagination_no_gap() -> None: def test_audit_log_pagination_no_gap() -> None:
"""审计游标分页跨页不丢/不重(回归 next_cursor off-by-one)。""" """审计分页跨页不丢/不重(offset 分页:cursor offset,翻完覆盖全部)。"""
from app.admin.repositories import admin_user as admin_repo from app.admin.repositories import admin_user as admin_repo
from app.admin.repositories import audit_log as audit_repo from app.admin.repositories import audit_log as audit_repo
@@ -142,12 +142,13 @@ def test_audit_log_pagination_no_gap() -> None:
) )
created_ids.append(log.id) created_ids.append(log.id)
# limit=2 翻 5 条,收集所有 id,应正好覆盖创建的 5 条(无丢无重) # limit=2 翻 5 条,收集所有 id,应正好覆盖创建的 5 条(无丢无重);total 恒为符合条件总数
seen: list[int] = [] seen: list[int] = []
cursor = None cursor = None
for _ in range(10): # 上限防死循环 for _ in range(10): # 上限防死循环
rows, cursor = audit_repo.list_audit_logs(db, action=action, limit=2, cursor=cursor) rows, cursor, total = audit_repo.list_audit_logs(db, action=action, limit=2, cursor=cursor)
seen.extend(r.id for r in rows) seen.extend(r.id for r in rows)
assert total == len(created_ids), f"total 应为 {len(created_ids)},得 {total}"
if cursor is None: if cursor is None:
break break
assert sorted(seen) == sorted(created_ids), f"分页丢/重: want={created_ids} got={seen}" assert sorted(seen) == sorted(created_ids), f"分页丢/重: want={created_ids} got={seen}"
+133
View File
@@ -91,6 +91,80 @@ def test_user_filter_by_status(admin_client: TestClient, admin_token: str) -> No
assert all(u["status"] == "active" for u in r.json()["items"]) assert all(u["status"] == "active" for u in r.json()["items"])
def test_user_list_sort_filter_range(admin_client: TestClient, admin_token: str) -> None:
"""用户列表:渠道/昵称筛选 + id 升降序 + 时间范围 + 非法 sort_by 422。"""
db = SessionLocal()
try:
u1 = user_repo.upsert_user_for_login(db, phone="13811110001", register_channel="sms")
u1.nickname = "排序测试甲"
u2 = user_repo.upsert_user_for_login(db, phone="13811110002", register_channel="wechat")
u2.nickname = "排序测试乙"
db.commit()
id1, id2 = u1.id, u2.id
finally:
db.close()
# 渠道精确筛选
r = admin_client.get(
"/admin/api/users", params={"register_channel": "wechat"}, headers=_auth(admin_token)
)
assert r.status_code == 200, r.text
assert all(u["register_channel"] == "wechat" for u in r.json()["items"])
# 昵称模糊筛选
r = admin_client.get(
"/admin/api/users", params={"nickname": "排序测试", "limit": 100}, headers=_auth(admin_token)
)
ids = {u["id"] for u in r.json()["items"]}
assert id1 in ids and id2 in ids
# id 升序 / 降序
asc_ids = [
u["id"]
for u in admin_client.get(
"/admin/api/users",
params={"sort_by": "id", "sort_order": "asc", "limit": 100},
headers=_auth(admin_token),
).json()["items"]
]
assert asc_ids == sorted(asc_ids)
desc_ids = [
u["id"]
for u in admin_client.get(
"/admin/api/users",
params={"sort_by": "id", "sort_order": "desc", "limit": 100},
headers=_auth(admin_token),
).json()["items"]
]
assert desc_ids == sorted(desc_ids, reverse=True)
# 时间范围:2000 年之前无人;之后有人(验证范围条件确实生效)
assert (
admin_client.get(
"/admin/api/users", params={"created_to": "2000-01-01T00:00:00Z"},
headers=_auth(admin_token),
).json()["items"]
== []
)
assert (
len(
admin_client.get(
"/admin/api/users", params={"created_from": "2000-01-01T00:00:00Z", "limit": 100},
headers=_auth(admin_token),
).json()["items"]
)
>= 1
)
# 非法 sort_by → 422
assert (
admin_client.get(
"/admin/api/users", params={"sort_by": "phone"}, headers=_auth(admin_token)
).status_code
== 422
)
def test_wallet_and_withdraw_lists(admin_client: TestClient, admin_token: str) -> None: def test_wallet_and_withdraw_lists(admin_client: TestClient, admin_token: str) -> None:
uid = _seed_user_with_data("13800000004") uid = _seed_user_with_data("13800000004")
r = admin_client.get( r = admin_client.get(
@@ -112,6 +186,65 @@ def test_feedback_list(admin_client: TestClient, admin_token: str) -> None:
assert all(f["status"] == "new" for f in r.json()["items"]) assert all(f["status"] == "new" for f in r.json()["items"])
def test_ad_coin_audit_full_count_truncate_and_only_mismatch(
admin_client: TestClient, admin_token: str
) -> None:
"""A+B:total/mismatch_count 按全量统计(不受 limit 影响),truncated 旗标 + only_mismatch 过滤。
capped 行造确定性数据(应发恒 0,coin==0 即一致),不依赖发奖公式:
3 coin=0(一致) + 2 coin=7(不一致) 全量 total=5mismatch=2
"""
from app.models.ad_reward import AdRewardRecord
d = "2020-01-15" # 固定历史日 + 独立 user,隔离其它用例数据
db = SessionLocal()
try:
uid = user_repo.upsert_user_for_login(db, phone="13800009999", register_channel="sms").id
for i in range(3):
db.add(AdRewardRecord(
trans_id=f"adaudit-ok-{i}", user_id=uid, coin=0, status="capped",
reward_scene="reward_video", reward_date=d,
))
for i in range(2):
db.add(AdRewardRecord(
trans_id=f"adaudit-bad-{i}", user_id=uid, coin=7, status="capped",
reward_scene="reward_video", reward_date=d,
))
db.commit()
finally:
db.close()
base = {"date": d, "user_id": uid}
# 全量:total=5、mismatch=2、不截断、返回 5 条
body = admin_client.get(
"/admin/api/ad-coin-audit", params={**base, "limit": 100}, headers=_auth(admin_token)
).json()
assert body["total"] == 5
assert body["mismatch_count"] == 2
assert body["truncated"] is False
assert len(body["items"]) == 5
# 截断:limit=2 → 统计仍全量、truncated=True、只回 2 条
body = admin_client.get(
"/admin/api/ad-coin-audit", params={**base, "limit": 2}, headers=_auth(admin_token)
).json()
assert body["total"] == 5 and body["mismatch_count"] == 2
assert body["truncated"] is True
assert len(body["items"]) == 2
# only_mismatch:只回 ✗ 行(2 条),统计仍全量、不截断
body = admin_client.get(
"/admin/api/ad-coin-audit",
params={**base, "limit": 100, "only_mismatch": True},
headers=_auth(admin_token),
).json()
assert body["total"] == 5 and body["mismatch_count"] == 2
assert body["truncated"] is False
assert len(body["items"]) == 2
assert all(it["matched"] is False for it in body["items"])
def test_read_apis_require_auth(admin_client: TestClient) -> None: def test_read_apis_require_auth(admin_client: TestClient) -> None:
"""所有 M2 读接口未带 token → 401(router 级 get_current_admin 守卫)。""" """所有 M2 读接口未带 token → 401(router 级 get_current_admin 守卫)。"""
for path in [ for path in [
+96
View File
@@ -136,6 +136,102 @@ def test_grant_zero_rejected(admin_client: TestClient, finance_token: str) -> No
assert r.status_code == 400 assert r.status_code == 400
def test_set_coins_writes_delta_txn(admin_client: TestClient, finance_token: str) -> None:
"""set 模式:先有余额,再设为目标值,只写一笔差值流水,审计带 mode/target/before。"""
uid = _seed_user("13900000009")
# 先增到 300
admin_client.post(
f"/admin/api/users/{uid}/coins", json={"amount": 300, "reason": ""},
headers=_auth(finance_token),
)
# 设为 100 → 差值 -200(扣减)
r = admin_client.post(
f"/admin/api/users/{uid}/coins", json={"mode": "set", "amount": 100, "reason": "设值"},
headers=_auth(finance_token),
)
assert r.status_code == 200, r.text
db = SessionLocal()
try:
assert db.get(CoinAccount, uid).coin_balance == 100
txns = db.execute(
select(CoinTransaction).where(CoinTransaction.user_id == uid)
.order_by(CoinTransaction.id.desc())
).scalars().all()
# 两笔:+300(admin_grant)、-200(admin_deduct)
assert txns[0].amount == -200 and txns[0].biz_type == "admin_deduct"
logs = db.execute(
select(AdminAuditLog).where(
AdminAuditLog.action == "user.coins.grant", AdminAuditLog.target_id == str(uid)
).order_by(AdminAuditLog.id.desc())
).scalars().all()
assert logs[0].detail["mode"] == "set"
assert logs[0].detail["target"] == 100
assert logs[0].detail["before"] == 300
assert logs[0].detail["amount"] == -200
finally:
db.close()
def test_set_coins_equal_balance_rejected(admin_client: TestClient, finance_token: str) -> None:
"""set 为当前余额(差值 0)→ 拒绝,不写流水。"""
uid = _seed_user("13900000010")
admin_client.post(
f"/admin/api/users/{uid}/coins", json={"amount": 50, "reason": ""},
headers=_auth(finance_token),
)
r = admin_client.post(
f"/admin/api/users/{uid}/coins", json={"mode": "set", "amount": 50, "reason": "x"},
headers=_auth(finance_token),
)
assert r.status_code == 400
db = SessionLocal()
try:
txns = db.execute(
select(CoinTransaction).where(CoinTransaction.user_id == uid)
).scalars().all()
assert len(txns) == 1 # 仅初始那笔,设值被拒后无新流水
finally:
db.close()
def test_set_coins_negative_target_rejected(admin_client: TestClient, finance_token: str) -> None:
uid = _seed_user("13900000011")
r = admin_client.post(
f"/admin/api/users/{uid}/coins", json={"mode": "set", "amount": -1, "reason": "x"},
headers=_auth(finance_token),
)
assert r.status_code == 400
def test_set_cash_writes_delta_txn(admin_client: TestClient, finance_token: str) -> None:
"""现金 set 模式:设为目标分值,写一笔差值流水。"""
uid = _seed_user("13900000012")
admin_client.post(
f"/admin/api/users/{uid}/cash", json={"amount_cents": 500, "reason": ""},
headers=_auth(finance_token),
)
r = admin_client.post(
f"/admin/api/users/{uid}/cash",
json={"mode": "set", "amount_cents": 200, "reason": "设值"},
headers=_auth(finance_token),
)
assert r.status_code == 200, r.text
db = SessionLocal()
try:
assert db.get(CoinAccount, uid).cash_balance_cents == 200
logs = db.execute(
select(AdminAuditLog).where(
AdminAuditLog.action == "user.cash.grant", AdminAuditLog.target_id == str(uid)
).order_by(AdminAuditLog.id.desc())
).scalars().all()
assert logs[0].detail["mode"] == "set"
assert logs[0].detail["target_cents"] == 200
assert logs[0].detail["before_cents"] == 500
assert logs[0].detail["amount_cents"] == -300
finally:
db.close()
# ===== 封号 ===== # ===== 封号 =====
def test_set_user_status_and_audit(admin_client: TestClient, operator_token: str) -> None: def test_set_user_status_and_audit(admin_client: TestClient, operator_token: str) -> None:
+156
View File
@@ -0,0 +1,156 @@
"""淘宝 deeplink 失效标记 + lookup 过滤 + invalidate 端点测试。
覆盖: shopId 标记所有行(幂等)lookup 过滤失效淘宝候选失效后新行仍可命中
invalidate 端点鉴权(503 未配 / 401 头错 / 200)端到端标记后 lookup MISS非淘宝 no-op
"""
from __future__ import annotations
import pytest
from app.core.config import settings
from app.db.session import SessionLocal
from app.models.store_mapping import StoreMapping
from app.repositories import store_mapping as repo
_SECRET = "test-internal-secret-only-for-pytest"
def _mk_row(db, *, trace_id, name_meituan=None, name_taobao=None,
id_taobao=None, deeplink=None, lat=None, lng=None,
name_jd=None, id_jd=None, jd_deeplink=None):
row = StoreMapping(
trace_id=trace_id, business_type="food",
name_meituan=name_meituan, name_taobao=name_taobao,
id_taobao=id_taobao, taobao_deeplink=deeplink, lat=lat, lng=lng,
name_jd=name_jd, id_jd=id_jd, jd_deeplink=jd_deeplink,
)
db.add(row)
db.commit()
db.refresh(row)
return row
@pytest.fixture()
def db():
"""每个用例前后清空 store_mapping(表由 conftest 的 create_all 建好,session 级共享)。"""
s = SessionLocal()
s.query(StoreMapping).delete()
s.commit()
yield s
s.query(StoreMapping).delete()
s.commit()
s.close()
# ---------- repo 层 ----------
def test_mark_invalid_marks_all_rows_with_shop_id(db):
# 同一个坏 shopId 散在两行(两次比价),另一行不同 shopId
_mk_row(db, trace_id="t1", name_meituan="绝味鸭脖", name_taobao="绝味鸭脖", id_taobao="BAD1", deeplink="dl_a")
_mk_row(db, trace_id="t2", name_meituan="绝味鸭脖", name_taobao="绝味鸭脖", id_taobao="BAD1", deeplink="dl_b")
_mk_row(db, trace_id="t3", name_meituan="绝味鸭脖", name_taobao="绝味鸭脖", id_taobao="GOOD9", deeplink="dl_g")
# 按 shopId 标记所有行
assert repo.mark_taobao_deeplink_invalid(db, "BAD1") == 2
# 幂等:已标记的不重复
assert repo.mark_taobao_deeplink_invalid(db, "BAD1") == 0
rows = {r.trace_id: r for r in db.query(StoreMapping).all()}
assert rows["t1"].taobao_deeplink_invalid_at is not None
assert rows["t2"].taobao_deeplink_invalid_at is not None
assert rows["t3"].taobao_deeplink_invalid_at is None # 不同 shopId 不动
def test_lookup_filters_invalid_taobao(db):
_mk_row(db, trace_id="t1", name_meituan="绝味鸭脖", name_taobao="绝味鸭脖", id_taobao="BAD1", deeplink="dl")
# 标记前命中
assert repo.lookup_nearest(db, "meituan", "绝味鸭脖")["taobao"]["shop_id"] == "BAD1"
# 标记失效后淘宝候选被过滤 → MISS(仅此一条淘宝)
repo.mark_taobao_deeplink_invalid(db, "BAD1")
assert "taobao" not in repo.lookup_nearest(db, "meituan", "绝味鸭脖")
def test_lookup_picks_new_valid_row_after_invalidate(db):
# 失效旧行 + 重搜写的新行(新 shopId,invalid_at=NULL)共存 → lookup 选到新行
_mk_row(db, trace_id="t1", name_meituan="店A", name_taobao="店A", id_taobao="BAD1", deeplink="dl_bad")
repo.mark_taobao_deeplink_invalid(db, "BAD1")
_mk_row(db, trace_id="t2", name_meituan="店A", name_taobao="店A", id_taobao="NEW2", deeplink="dl_new")
assert repo.lookup_nearest(db, "meituan", "店A")["taobao"]["shop_id"] == "NEW2"
# ---------- invalidate 端点 ----------
def test_invalidate_endpoint_secret_unset_503(client, monkeypatch):
monkeypatch.setattr(settings, "INTERNAL_API_SECRET", "") # 未配 = 端点关闭
r = client.post("/internal/store-mapping/invalidate",
json={"platform": "taobao", "shop_id": "X"})
assert r.status_code == 503
def test_invalidate_endpoint_auth(client, monkeypatch):
monkeypatch.setattr(settings, "INTERNAL_API_SECRET", _SECRET)
body = {"platform": "taobao", "shop_id": "X"}
assert client.post("/internal/store-mapping/invalidate", json=body).status_code == 401
assert client.post("/internal/store-mapping/invalidate", json=body,
headers={"X-Internal-Secret": "wrong"}).status_code == 401
r = client.post("/internal/store-mapping/invalidate", json=body,
headers={"X-Internal-Secret": _SECRET})
assert r.status_code == 200 and r.json()["ok"] is True
def test_invalidate_endpoint_marks_and_lookup_miss(client, db, monkeypatch):
monkeypatch.setattr(settings, "INTERNAL_API_SECRET", _SECRET)
_mk_row(db, trace_id="t1", name_meituan="店B", name_taobao="店B", id_taobao="BADX", deeplink="dl")
_mk_row(db, trace_id="t2", name_meituan="店B", name_taobao="店B", id_taobao="BADX", deeplink="dl2")
r = client.post("/internal/store-mapping/invalidate",
json={"platform": "taobao", "shop_id": "BADX"},
headers={"X-Internal-Secret": _SECRET})
assert r.status_code == 200 and r.json()["affected"] == 2
lk = client.get("/internal/store-mapping/lookup",
params={"source_platform": "meituan", "name": "店B"},
headers={"X-Internal-Secret": _SECRET})
assert "taobao" not in lk.json()
def test_invalidate_endpoint_unsupported_platform_noop(client, monkeypatch):
# 当前支持 taobao/jd;其它平台(如 meituan)no-op 返 affected=0
monkeypatch.setattr(settings, "INTERNAL_API_SECRET", _SECRET)
r = client.post("/internal/store-mapping/invalidate",
json={"platform": "meituan", "shop_id": "X"},
headers={"X-Internal-Secret": _SECRET})
assert r.status_code == 200 and r.json()["affected"] == 0
# ---------- 京东(对称淘宝)----------
def test_mark_jd_invalid_marks_all_rows_with_store_id(db):
_mk_row(db, trace_id="j1", name_meituan="兰州拉面", name_jd="兰州拉面", id_jd="JBAD", jd_deeplink="dl_a")
_mk_row(db, trace_id="j2", name_meituan="兰州拉面", name_jd="兰州拉面", id_jd="JBAD", jd_deeplink="dl_b")
_mk_row(db, trace_id="j3", name_meituan="兰州拉面", name_jd="兰州拉面", id_jd="JGOOD", jd_deeplink="dl_g")
assert repo.mark_jd_deeplink_invalid(db, "JBAD") == 2
assert repo.mark_jd_deeplink_invalid(db, "JBAD") == 0 # 幂等
rows = {r.trace_id: r for r in db.query(StoreMapping).all()}
assert rows["j1"].jd_deeplink_invalid_at is not None
assert rows["j2"].jd_deeplink_invalid_at is not None
assert rows["j3"].jd_deeplink_invalid_at is None # 不同 storeId 不动
def test_lookup_filters_invalid_jd(db):
_mk_row(db, trace_id="j1", name_meituan="兰州拉面", name_jd="兰州拉面", id_jd="JBAD", jd_deeplink="dl")
assert repo.lookup_nearest(db, "meituan", "兰州拉面")["jd"]["store_id"] == "JBAD"
repo.mark_jd_deeplink_invalid(db, "JBAD")
assert "jd" not in repo.lookup_nearest(db, "meituan", "兰州拉面")
def test_invalidate_endpoint_jd_marks_and_lookup_miss(client, db, monkeypatch):
monkeypatch.setattr(settings, "INTERNAL_API_SECRET", _SECRET)
_mk_row(db, trace_id="j1", name_meituan="店J", name_jd="店J", id_jd="JBADX", jd_deeplink="dl")
_mk_row(db, trace_id="j2", name_meituan="店J", name_jd="店J", id_jd="JBADX", jd_deeplink="dl2")
r = client.post("/internal/store-mapping/invalidate",
json={"platform": "jd", "shop_id": "JBADX"},
headers={"X-Internal-Secret": _SECRET})
assert r.status_code == 200 and r.json()["affected"] == 2
lk = client.get("/internal/store-mapping/lookup",
params={"source_platform": "meituan", "name": "店J"},
headers={"X-Internal-Secret": _SECRET})
assert "jd" not in lk.json()