Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 824045dd19 | |||
| 930eff822c | |||
| 285e46ebaf | |||
| 3630fb7b3a | |||
| 2ddea4159d | |||
| 37fd51a498 | |||
| fa4127b9e5 | |||
| 0cf5b3816f | |||
| 9e082e6376 | |||
| 93cb283c1b | |||
| a43e082391 | |||
| e11ad9449c | |||
| 6ec4cfb4be | |||
| a52fc1973f | |||
| 58b59c264d | |||
| 0e149c83e7 | |||
| 9e011f699a | |||
| 66c1e3ad8f | |||
| 982e92bc24 | |||
| 6241543119 | |||
| 7b6756f936 |
@@ -48,3 +48,12 @@ secrets/*
|
||||
# 运行日志(run.sh 输出, 不入库)
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Claude Code 自动持久化的权限 allowlist / 个人本地设置(会话专属,不入库)。
|
||||
# 需要团队共享的 Claude 配置(commands/ 等)可单独 git add -f,不受此忽略影响。
|
||||
.claude/settings.json
|
||||
.claude/settings.local.json
|
||||
tests/meituan_coupon_bj.tsv
|
||||
tests/meituan_coupon_data.tsv
|
||||
tests/meituan_coupon_fz.tsv
|
||||
tests/meituan_coupon_xm.tsv
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""analytics_selfstat tables
|
||||
|
||||
Revision ID: 11c44afbea58
|
||||
Revises: admin_user_plain_password
|
||||
Create Date: 2026-07-08 16:32:49.351817
|
||||
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '11c44afbea58'
|
||||
down_revision: str | Sequence[str] | None = 'admin_user_plain_password'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'analytics_selfstat',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('device_id', sa.String(length=64), nullable=False),
|
||||
sa.Column('epoch_id', sa.String(length=64), nullable=False),
|
||||
sa.Column('app_ver', sa.String(length=32), nullable=True),
|
||||
sa.Column('oem', sa.String(length=32), nullable=True),
|
||||
sa.Column('os', sa.String(length=32), nullable=True),
|
||||
sa.Column('batches_attempted', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.Column('batches_ok', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.Column('batches_fail', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.Column('retries', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.Column('queue_depth', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('sent_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True),
|
||||
server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
with op.batch_alter_table('analytics_selfstat', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_analytics_selfstat_created_at'), ['created_at'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_analytics_selfstat_device_id'), ['device_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_analytics_selfstat_epoch_id'), ['epoch_id'], unique=False)
|
||||
|
||||
op.create_table(
|
||||
'analytics_selfstat_event',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('snapshot_id', sa.Integer(), nullable=False),
|
||||
sa.Column('event', sa.String(length=64), nullable=False),
|
||||
sa.Column('attempted', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.Column('drop_capture', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.Column('delivered', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.Column('drop_undelivered', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.ForeignKeyConstraint(['snapshot_id'], ['analytics_selfstat.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
with op.batch_alter_table('analytics_selfstat_event', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_analytics_selfstat_event_event'), ['event'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_analytics_selfstat_event_snapshot_id'), ['snapshot_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('analytics_selfstat_event', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_analytics_selfstat_event_snapshot_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_analytics_selfstat_event_event'))
|
||||
op.drop_table('analytics_selfstat_event')
|
||||
with op.batch_alter_table('analytics_selfstat', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_analytics_selfstat_epoch_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_analytics_selfstat_device_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_analytics_selfstat_created_at'))
|
||||
op.drop_table('analytics_selfstat')
|
||||
@@ -0,0 +1,44 @@
|
||||
"""ad_ecpm_record.trace_id(展示收益归属到比价/领券 trace)
|
||||
|
||||
信息流(Draw)展示 eCPM 上报时带上本场比价/领券 trace_id,落此列;领券数据 / 比价记录看板
|
||||
按 trace_id 聚合"本次广告收益"。激励视频/福利/旧客户端为 NULL。
|
||||
|
||||
本迁移原以 (11c44afbea58, merge_pages_override_coupon_slot) 为双亲、顺带收敛双 head,
|
||||
但与它并行落 main 的 merge_selfstat_coupon_slot 已用同一对双亲做了纯收敛 → 同一对
|
||||
父节点出现两个收敛点、main 上又成双 head。故重挂到该 merge 之后成单链(仅改链接、
|
||||
schema 改动不变;两文件都保留,已 stamp 在 merge 上的库可直接线性升级)。
|
||||
|
||||
Revision ID: ad_ecpm_trace_id
|
||||
Revises: merge_selfstat_coupon_slot
|
||||
Create Date: 2026-07-10
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "ad_ecpm_trace_id"
|
||||
down_revision: Union[str, Sequence[str], None] = "merge_selfstat_coupon_slot"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# SQLite 下 ADD COLUMN(可空)与 CREATE INDEX 均原生支持,无需 batch_alter_table
|
||||
# (同 ad_feed_reward_trace_id 迁移)。
|
||||
op.add_column(
|
||||
"ad_ecpm_record",
|
||||
sa.Column("trace_id", sa.String(length=64), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_ad_ecpm_record_trace_id"),
|
||||
"ad_ecpm_record",
|
||||
["trace_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_ad_ecpm_record_trace_id"), table_name="ad_ecpm_record")
|
||||
op.drop_column("ad_ecpm_record", "trace_id")
|
||||
@@ -0,0 +1,69 @@
|
||||
"""admin_role 表(RBAC 角色 → 可见页面)+ 种子内建角色
|
||||
|
||||
新增后台 RBAC:角色持有一组页面 key,登录后左侧只展示这些页。super_admin 内建全权(pages 存空、
|
||||
effective_pages 特判为全部)、不可编辑删除;operator/finance 播默认页集,可由 super_admin 增删改。
|
||||
admin_user.role 弱引用本表 name。全新环境顺序应用即得三条内建角色。
|
||||
|
||||
Revision ID: admin_role_table
|
||||
Revises: comparison_product_names
|
||||
Create Date: 2026-07-04 00:00:00.000000
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "admin_role_table"
|
||||
down_revision: str | Sequence[str] | None = "comparison_product_names"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
|
||||
|
||||
# 与 app/admin/permissions.py 的 BUILTIN_ROLES 同口径(迁移内联一份,不耦合 app 常量)。
|
||||
# 页集对齐 Prototypes/dashboard/permissions.md 的 ROLES。key 承重(require_role),label 为展示名。
|
||||
_BUILTIN = [
|
||||
{"name": "super_admin", "label": "管理员", "pages": [], "is_builtin": True},
|
||||
{"name": "operator", "label": "运营", "pages": [
|
||||
"dashboard", "coupon-data", "ad-revenue-report", "comparison-records",
|
||||
"cps", "device-liveness", "price-reports", "feedbacks",
|
||||
], "is_builtin": False},
|
||||
{"name": "finance", "label": "财务", "pages": [
|
||||
"dashboard", "ad-revenue-report", "cps", "withdraws",
|
||||
], "is_builtin": False},
|
||||
{"name": "tech", "label": "技术", "pages": [
|
||||
"dashboard", "device-liveness", "config", "ad-revenue", "event-logs", "audit-logs",
|
||||
], "is_builtin": False},
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"admin_role",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("name", sa.String(length=32), nullable=False),
|
||||
sa.Column("label", sa.String(length=32), nullable=False, server_default=""),
|
||||
sa.Column("pages", _JSON, nullable=False),
|
||||
sa.Column("is_builtin", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(), nullable=False,
|
||||
),
|
||||
)
|
||||
op.create_index("ix_admin_role_name", "admin_role", ["name"], unique=True)
|
||||
|
||||
tbl = sa.table(
|
||||
"admin_role",
|
||||
sa.column("name", sa.String),
|
||||
sa.column("label", sa.String),
|
||||
sa.column("pages", _JSON),
|
||||
sa.column("is_builtin", sa.Boolean),
|
||||
)
|
||||
op.bulk_insert(tbl, _BUILTIN)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_admin_role_name", table_name="admin_role")
|
||||
op.drop_table("admin_role")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""admin_user 加 pages_override 列(「自定义」权限:按人存专属可见页 key 列表)
|
||||
|
||||
权限管理页新增「自定义」角色:选它时该成员的可见页不跟随任何共享角色,而由逐页勾选决定,
|
||||
存这个人专属的一份页 key 列表。仅 role == "custom" 时有效;普通角色为 None(可见页跟随角色)。
|
||||
PG 用 JSONB,SQLite 退化为通用 JSON(同 admin_audit_log.detail / comparison_record.raw_payload)。
|
||||
|
||||
Revision ID: admin_user_pages_override
|
||||
Revises: admin_user_plain_password
|
||||
Create Date: 2026-07-08 00:00:00.000000
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "admin_user_pages_override"
|
||||
down_revision: str | Sequence[str] | None = "admin_user_plain_password"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
# 与 app/models/admin.py 的 _JSON 一致:PG JSONB / 其它 JSON
|
||||
_JSON = sa.JSON().with_variant(JSONB(), "postgresql")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("admin_user", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("pages_override", _JSON, nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("admin_user", schema=None) as batch_op:
|
||||
batch_op.drop_column("pages_override")
|
||||
@@ -0,0 +1,29 @@
|
||||
"""admin_user 加 plain_password 列(明文登录密码,仅后台 UI 建/重置的账号留存)
|
||||
|
||||
权限管理页需能复看某成员已确定的登录密码转交本人。系统只存哈希无法反推,故对「后台 UI 创建/重置」
|
||||
的管理员额外留存一份明文;脚本/起后台建的超管为 None(前端不显示密码)。旧账号无此列值 → 同样不显示。
|
||||
|
||||
Revision ID: admin_user_plain_password
|
||||
Revises: admin_role_table
|
||||
Create Date: 2026-07-04 00:00:00.000000
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "admin_user_plain_password"
|
||||
down_revision: str | Sequence[str] | None = "admin_role_table"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("admin_user", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("plain_password", sa.String(length=128), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("admin_user", schema=None) as batch_op:
|
||||
batch_op.drop_column("plain_password")
|
||||
@@ -0,0 +1,70 @@
|
||||
"""comparison_record 加 product_names 列(下单商品名派生串)+ 回填历史行
|
||||
|
||||
admin 比价记录页要把原「店/商品」一列拆成「店」+「商品」两列、并支持按商品名搜索。
|
||||
items 是 JSON(SQLite 下 json.dumps ensure_ascii 会把中文转义存成 \\uXXXX,无法直接 CAST+LIKE
|
||||
命中中文),故把商品名派生成普通文本列,跨库 LIKE 一致、可索引。
|
||||
|
||||
写路径(upsert_record / harvest_done)已同步派生;本迁移建列并从已存 items 回填历史行。
|
||||
全新环境顺序应用即得空列 + 回填(表本为空,回填 no-op)。
|
||||
|
||||
Revision ID: comparison_product_names
|
||||
Revises: comparison_record_trace_unique
|
||||
Create Date: 2026-07-04 00:00:00.000000
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "comparison_product_names"
|
||||
down_revision: str | Sequence[str] | None = "comparison_record_trace_unique"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _product_names(items) -> str | None:
|
||||
"""items([{name,...}]) → 顿号分隔的商品名串(去重保序)。与 repositories.comparison
|
||||
._product_names_from_items 同口径,迁移内联一份避免耦合 app 代码。"""
|
||||
if isinstance(items, str): # SQLite: items 以 JSON 文本存,取回是 str
|
||||
try:
|
||||
items = json.loads(items)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if not isinstance(items, list) or not items:
|
||||
return None
|
||||
names: list[str] = []
|
||||
for it in items:
|
||||
name = it.get("name") if isinstance(it, dict) else None
|
||||
if not name:
|
||||
continue
|
||||
s = str(name).strip()
|
||||
if s and s not in names:
|
||||
names.append(s)
|
||||
joined = "、".join(names)
|
||||
return joined[:500] or None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("comparison_record", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("product_names", sa.String(length=512), nullable=True))
|
||||
|
||||
# 回填:从已存 items 派生商品名。items 小(菜名列表),一次取回即可;只更新有商品名的行。
|
||||
bind = op.get_bind()
|
||||
rows = bind.execute(
|
||||
sa.text("SELECT id, items FROM comparison_record")
|
||||
).fetchall()
|
||||
for rid, items in rows:
|
||||
pn = _product_names(items)
|
||||
if pn:
|
||||
bind.execute(
|
||||
sa.text("UPDATE comparison_record SET product_names = :pn WHERE id = :id"),
|
||||
{"pn": pn, "id": rid},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("comparison_record", schema=None) as batch_op:
|
||||
batch_op.drop_column("product_names")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""coupon_claim_record 加 app_env 列(领券所属 session 环境;每券成功率表按它过滤 prod/dev)
|
||||
|
||||
Revision ID: coupon_claim_app_env
|
||||
Revises: coupon_session_platform_success
|
||||
Create Date: 2026-07-08 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "coupon_claim_app_env"
|
||||
down_revision: str | Sequence[str] | None = "coupon_session_platform_success"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("coupon_claim_record", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("app_env", sa.String(length=16), nullable=True))
|
||||
batch_op.create_index(
|
||||
"ix_coupon_claim_record_app_env", ["app_env"], unique=False
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("coupon_claim_record", schema=None) as batch_op:
|
||||
batch_op.drop_index("ix_coupon_claim_record_app_env")
|
||||
batch_op.drop_column("app_env")
|
||||
@@ -0,0 +1,37 @@
|
||||
"""coupon_session 加 platform_success 列(本次至少领到一张的平台 id 列表)
|
||||
|
||||
供 admin「领券数据」算 ②整单成功率 / ③点位成功率(平台粒度)。数据落点:服务端 /step 逐帧
|
||||
按 trace_id 并集写入(见 app/repositories/coupon_state.merge_session_platform_success)。旧行 NULL
|
||||
视作空集,已建表环境靠它补列、全新环境顺序应用不重复加列。设计:docs/guides/领券成功率指标-设计与埋点.md。
|
||||
|
||||
Revision ID: coupon_session_platform_success
|
||||
Revises: admin_user_plain_password
|
||||
Create Date: 2026-07-07 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "coupon_session_platform_success"
|
||||
down_revision: str | Sequence[str] | None = "admin_user_plain_password"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
# PG 用 JSONB,SQLite(本地/测试)退化为通用 JSON(同 model 的 _JSON variant / 建表迁移)。
|
||||
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("platform_success", _JSON, nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
|
||||
batch_op.drop_column("platform_success")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""合并两个 alembic head:admin_user_pages_override(#126 权限)+ coupon_claim_app_env(领券成功率)。
|
||||
|
||||
两条迁移都从 admin_user_plain_password 分叉——#126 经 pull main 进入本分支,领券成功率为本分支新增——
|
||||
于是出现两个 head。本迁移仅把二者收敛成单 head,让 `alembic upgrade head`(单数,部署/run.sh 用)
|
||||
恢复正常;**不含任何表结构 / 数据改动**(纯 merge)。
|
||||
|
||||
Revision ID: merge_pages_override_coupon_slot
|
||||
Revises: admin_user_pages_override, coupon_claim_app_env
|
||||
Create Date: 2026-07-09 00:00:00.000000
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
revision: str = "merge_pages_override_coupon_slot"
|
||||
down_revision: str | Sequence[str] | None = (
|
||||
"admin_user_pages_override",
|
||||
"coupon_claim_app_env",
|
||||
)
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""纯合并 head,无 schema 改动。"""
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""拆回两个 head,无 schema 改动。"""
|
||||
@@ -0,0 +1,29 @@
|
||||
"""合并两个 alembic head:11c44afbea58(#127 埋点健康度 selfstat)+ merge_pages_override_coupon_slot(#130 自带的合并迁移)。
|
||||
|
||||
三条分支都从 admin_user_plain_password 分叉(#126 权限 / #127 selfstat / #130 领券成功率)。
|
||||
#130 自带的 merge 创建时本地 main 尚无 #127 的 11c44afbea58,只收敛了 #126 + 自身两条,
|
||||
#130 合入后 main 上仍留两个 head → `alembic upgrade head`(单数,部署/run.sh 用)直接报错、服务起不来。
|
||||
本迁移仅把二者收敛成单 head;**不含任何表结构 / 数据改动**(纯 merge)。
|
||||
|
||||
Revision ID: merge_selfstat_coupon_slot
|
||||
Revises: 11c44afbea58, merge_pages_override_coupon_slot
|
||||
Create Date: 2026-07-10 00:00:00.000000
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
revision: str = "merge_selfstat_coupon_slot"
|
||||
down_revision: str | Sequence[str] | None = (
|
||||
"11c44afbea58",
|
||||
"merge_pages_override_coupon_slot",
|
||||
)
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""纯合并 head,无 schema 改动。"""
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""拆回两个 head,无 schema 改动。"""
|
||||
@@ -26,12 +26,14 @@ from app.admin.routers.cps import router as cps_router
|
||||
from app.admin.routers.dashboard import router as dashboard_router
|
||||
from app.admin.routers.device_liveness import router as device_liveness_router
|
||||
from app.admin.routers.ops_stat_config import router as ops_stat_config_router
|
||||
from app.admin.routers.analytics_health import router as analytics_health_router
|
||||
from app.admin.routers.event_logs import router as event_logs_router
|
||||
from app.admin.routers.feedback import router as feedback_router
|
||||
from app.admin.routers.feedback_qr import router as feedback_qr_router
|
||||
from app.admin.routers.onboarding import router as onboarding_router
|
||||
from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router
|
||||
from app.admin.routers.price_report import router as price_report_router
|
||||
from app.admin.routers.roles import router as roles_router
|
||||
from app.admin.routers.users import router as users_router
|
||||
from app.admin.routers.wallet import router as wallet_router
|
||||
from app.admin.routers.withdraw import router as withdraw_router
|
||||
@@ -96,8 +98,10 @@ admin_app.include_router(withdraw_router)
|
||||
admin_app.include_router(price_report_router)
|
||||
admin_app.include_router(feedback_router)
|
||||
admin_app.include_router(event_logs_router)
|
||||
admin_app.include_router(analytics_health_router)
|
||||
admin_app.include_router(feedback_qr_router)
|
||||
admin_app.include_router(admins_router)
|
||||
admin_app.include_router(roles_router)
|
||||
admin_app.include_router(audit_router)
|
||||
admin_app.include_router(config_router)
|
||||
admin_app.include_router(comparison_router)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""admin 后台「页面权限目录」—— RBAC 的权限侧单一真源。
|
||||
|
||||
一个权限 = 一个页面(= 左侧导航项)。角色持有一组 page key,登录后左侧只展示这些页(见前端
|
||||
layout.tsx 按 pages 过滤导航)。key 必须与前端路由一级对齐(/dashboard → "dashboard")。
|
||||
|
||||
super_admin 为内建全权角色,恒可见全部页(effective_pages 特判)。新增页面 = 这里加一条 +
|
||||
前端导航加对应项(key 一致即可被各角色勾选可见)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
SUPER_ADMIN_ROLE = "super_admin"
|
||||
# 「自定义」哨兵角色:不是 admin_role 表里的行,而是标记「这个人的可见页由 pages_override 决定」。
|
||||
# admin_user.role == CUSTOM_ROLE 时,有效可见页取 admin_user.pages_override(见 auth._admin_out_with_pages)。
|
||||
CUSTOM_ROLE = "custom"
|
||||
|
||||
# 分组镜像前端导航(app/(main)/layout.tsx 的 NAV_GROUPS);key = 路由一级
|
||||
PERMISSION_CATALOG: list[dict] = [
|
||||
{"group": "看板", "pages": [
|
||||
{"key": "dashboard", "label": "数据大盘"},
|
||||
{"key": "coupon-data", "label": "领券数据"},
|
||||
{"key": "ad-revenue-report", "label": "广告收益"},
|
||||
{"key": "comparison-records", "label": "比价记录"},
|
||||
{"key": "cps", "label": "CPS收益"},
|
||||
{"key": "device-liveness", "label": "设备存活"},
|
||||
]},
|
||||
{"group": "奖励审核", "pages": [
|
||||
{"key": "withdraws", "label": "提现审核"},
|
||||
{"key": "price-reports", "label": "低价审核"},
|
||||
{"key": "feedbacks", "label": "用户反馈"},
|
||||
]},
|
||||
{"group": "数据配置", "pages": [
|
||||
{"key": "config", "label": "系统配置"},
|
||||
{"key": "ad-revenue", "label": "广告配置"},
|
||||
{"key": "users", "label": "用户管理"},
|
||||
]},
|
||||
{"group": "其他", "pages": [
|
||||
{"key": "admins", "label": "权限管理"},
|
||||
{"key": "event-logs", "label": "埋点日志"},
|
||||
{"key": "audit-logs", "label": "审计日志"},
|
||||
]},
|
||||
]
|
||||
|
||||
# 全部页面 key(super_admin 有效可见 = 此全集;也用于校验角色 pages 合法性)
|
||||
ALL_PAGE_KEYS: list[str] = [p["key"] for g in PERMISSION_CATALOG for p in g["pages"]]
|
||||
_ALL_SET = set(ALL_PAGE_KEYS)
|
||||
|
||||
# 内建角色定义(种子 / ensure 兜底的单一真源):(key, 中文展示名, 默认可见页)。
|
||||
# 页集对齐 Prototypes/dashboard/permissions.md 的 ROLES;super_admin 恒全权(pages 空、bypass)。
|
||||
# key 承重(require_role / admin_user.role),勿改;label 为 UI 展示名。
|
||||
BUILTIN_ROLES: list[dict] = [
|
||||
{"name": SUPER_ADMIN_ROLE, "label": "管理员", "pages": []},
|
||||
{"name": "operator", "label": "运营", "pages": [
|
||||
"dashboard", "coupon-data", "ad-revenue-report", "comparison-records",
|
||||
"cps", "device-liveness", "price-reports", "feedbacks",
|
||||
]},
|
||||
{"name": "finance", "label": "财务", "pages": [
|
||||
"dashboard", "ad-revenue-report", "cps", "withdraws",
|
||||
]},
|
||||
{"name": "tech", "label": "技术", "pages": [
|
||||
"dashboard", "device-liveness", "config", "ad-revenue", "event-logs", "audit-logs",
|
||||
]},
|
||||
]
|
||||
|
||||
# 内建 key → 中文展示名(展示 / 自愈用)
|
||||
BUILTIN_LABELS: dict[str, str] = {r["name"]: r["label"] for r in BUILTIN_ROLES}
|
||||
|
||||
|
||||
def sanitize_pages(pages: list[str] | None) -> list[str]:
|
||||
"""过滤掉不在目录里的 key(去重、保序),防脏数据/目录收缩后的悬空 key。"""
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for k in pages or []:
|
||||
if k in _ALL_SET and k not in seen:
|
||||
seen.add(k)
|
||||
out.append(k)
|
||||
return out
|
||||
|
||||
|
||||
def effective_pages(role_name: str, role_pages: list[str] | None) -> list[str]:
|
||||
"""某角色的有效可见页:super_admin → 全部;其余 → 其 pages 与目录取交(自愈悬空 key)。"""
|
||||
if role_name == SUPER_ADMIN_ROLE:
|
||||
return list(ALL_PAGE_KEYS)
|
||||
return sanitize_pages(role_pages)
|
||||
@@ -385,10 +385,33 @@ def ad_revenue_report(
|
||||
for k, v in type_map.items()
|
||||
}
|
||||
|
||||
# DAU:复用大盘「今日活跃」口径(stats.today_dau,last_login_at)。该口径只能算今日,
|
||||
# 故仅当查询=今日单天时给值;历史 / 多天区间返回 None,前端显示「-」。
|
||||
is_today = date_from == date_to == rewards.cn_today().isoformat()
|
||||
dau = admin_stats.today_dau(db) if is_today else None
|
||||
# 分场景小计(按 feed_scene:展示条数 + 预估收益),同 type_stats 基于全量 events——
|
||||
# 供数据大盘「领券广告 / 比价广告」卡用。此前大盘是在分页 items 里按 feed_scene 现算,
|
||||
# 2026-07-02 起信息流逐条展示行(唯一带收益 + 场景的行)不再进主表 items,现算恒为 0;
|
||||
# 改为服务端在全量上聚合下发(也顺带不受 limit 分页截断影响)。feed_scene 为空(激励视频 /
|
||||
# 旧数据)不计入任何场景桶。
|
||||
scene_map: dict[str, dict] = {}
|
||||
for e in events:
|
||||
sc = e.get("feed_scene")
|
||||
if not sc:
|
||||
continue
|
||||
s = scene_map.get(sc)
|
||||
if s is None:
|
||||
s = {"impressions": 0, "revenue_yuan": 0.0}
|
||||
scene_map[sc] = s
|
||||
s["impressions"] += e["impressions"]
|
||||
s["revenue_yuan"] += e["revenue_yuan"]
|
||||
scene_stats = {
|
||||
k: {"impressions": v["impressions"], "revenue_yuan": round(v["revenue_yuan"], 6)}
|
||||
for k, v in scene_map.items()
|
||||
}
|
||||
|
||||
# DAU:复用数据大盘活跃用户口径(登录 + 开始比价 + 开始领券,按用户去重),按所选日期区间
|
||||
# 统计(含今日),历史 / 多天区间同样有值。ARPU = 区间预估收益 ÷ 区间活跃用户。全局口径,
|
||||
# 不随 user / ad_type / feed_scene / app_env 筛选变化(活跃用户口径无这些维度)。
|
||||
dau = admin_stats.period_active_dau(
|
||||
db, _date.fromisoformat(date_from), _date.fromisoformat(date_to)
|
||||
)
|
||||
|
||||
# 主表「逐行」= 单次广告行为(2026-07 按「一次比价/领券放一块」聚合):激励视频 = 一次观看一行(展示+发奖
|
||||
# 按 ad_session_id 合并);一次比价 / 一次领券 = 该次整场多条广告按 ad_session_id 聚成一行(展开看逐条)。
|
||||
@@ -416,6 +439,7 @@ def ad_revenue_report(
|
||||
"daily": daily,
|
||||
"hourly": hourly,
|
||||
"type_stats": type_stats,
|
||||
"scene_stats": scene_stats,
|
||||
"dau": dau,
|
||||
"items": main_rows[offset:offset + limit],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""admin_role 表 CRUD + 「角色 → 有效可见页」解析。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.admin.permissions import (
|
||||
ALL_PAGE_KEYS,
|
||||
BUILTIN_ROLES,
|
||||
SUPER_ADMIN_ROLE,
|
||||
sanitize_pages,
|
||||
)
|
||||
from app.models.admin_role import AdminRole
|
||||
|
||||
|
||||
def ensure_builtin_roles(db: Session) -> None:
|
||||
"""空表时播种内建角色(管理员/运营/财务/技术)。幂等:表非空即 no-op。
|
||||
给 create_all 环境(测试 / 未跑迁移的全新库)兜底,迁移已播种则不重复。
|
||||
仅 super_admin 为 is_builtin(锁定不可改删);运营/财务/技术可编辑页/删除。"""
|
||||
if db.execute(select(AdminRole.id).limit(1)).first() is not None:
|
||||
return
|
||||
db.add_all([
|
||||
AdminRole(
|
||||
name=r["name"], label=r["label"], pages=list(r["pages"]),
|
||||
is_builtin=(r["name"] == SUPER_ADMIN_ROLE),
|
||||
)
|
||||
for r in BUILTIN_ROLES
|
||||
])
|
||||
db.commit()
|
||||
|
||||
|
||||
def list_roles(db: Session) -> list[AdminRole]:
|
||||
ensure_builtin_roles(db)
|
||||
# super_admin(内建)恒排最前,其余按创建序
|
||||
return list(
|
||||
db.execute(
|
||||
select(AdminRole).order_by(AdminRole.is_builtin.desc(), AdminRole.id)
|
||||
).scalars().all()
|
||||
)
|
||||
|
||||
|
||||
def get_role(db: Session, name: str) -> AdminRole | None:
|
||||
return db.execute(
|
||||
select(AdminRole).where(AdminRole.name == name)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def create_role(db: Session, *, name: str, label: str, pages: list[str]) -> AdminRole:
|
||||
# 自定义角色:name(key)= label = 用户输入的名称
|
||||
role = AdminRole(name=name, label=label, pages=sanitize_pages(pages), is_builtin=False)
|
||||
db.add(role)
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
return role
|
||||
|
||||
|
||||
def update_role(
|
||||
db: Session, role: AdminRole, *, label: str | None = None, pages: list[str] | None = None
|
||||
) -> AdminRole:
|
||||
# 只改展示名 label + 可见页;name(key)不可变(admin_user.role / require_role 承重)
|
||||
if label is not None:
|
||||
role.label = label
|
||||
if pages is not None:
|
||||
role.pages = sanitize_pages(pages)
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
return role
|
||||
|
||||
|
||||
def delete_role(db: Session, role: AdminRole) -> None:
|
||||
db.delete(role)
|
||||
db.commit()
|
||||
|
||||
|
||||
def effective_pages_of(db: Session, role_name: str) -> list[str]:
|
||||
"""某角色名的有效可见页:super_admin → 全部;其余 → 查表 pages 与目录取交。"""
|
||||
if role_name == SUPER_ADMIN_ROLE:
|
||||
return list(ALL_PAGE_KEYS)
|
||||
ensure_builtin_roles(db)
|
||||
role = get_role(db, role_name)
|
||||
return sanitize_pages(role.pages if role else None)
|
||||
@@ -20,12 +20,23 @@ def get_by_username(db: Session, username: str) -> AdminUser | None:
|
||||
|
||||
|
||||
def create_admin(
|
||||
db: Session, *, username: str, password: str, role: str = "operator"
|
||||
db: Session,
|
||||
*,
|
||||
username: str,
|
||||
password: str,
|
||||
role: str = "operator",
|
||||
plain_password: str | None = None,
|
||||
pages_override: list[str] | None = None,
|
||||
) -> AdminUser:
|
||||
"""建管理员。plain_password 非空则额外留存明文(后台 UI 建的账号传,供权限管理页复看);
|
||||
脚本/起后台建账号不传(留 None → 前端不显示密码)。
|
||||
pages_override:role=="custom" 时传专属可见页 key 列表;普通角色为 None。"""
|
||||
admin = AdminUser(
|
||||
username=username,
|
||||
password_hash=hash_password(password),
|
||||
role=role,
|
||||
plain_password=plain_password,
|
||||
pages_override=pages_override,
|
||||
)
|
||||
db.add(admin)
|
||||
db.commit()
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""埋点健康度聚合(埋点成功率 / 上报成功率)。
|
||||
|
||||
只存原始累计快照,查询时在 Python 侧差分聚合(admin 低频、量级小,跨 PG/SQLite 无方言坑;
|
||||
与 cps.py / coupon_data.py 同款约定)。差分按 (device_id, epoch_id, event) 分区、created_at
|
||||
升序,相邻做差、负值夹 0;每增量按其快照 created_at 归入北京天桶。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
from app.models.analytics_selfstat import AnalyticsSelfStat as H
|
||||
from app.models.analytics_selfstat import AnalyticsSelfStatEvent as E
|
||||
|
||||
_COUNTS = ("attempted", "drop_capture", "delivered", "drop_undelivered")
|
||||
|
||||
|
||||
def diff_snapshots(rows: list[dict]) -> list[dict]:
|
||||
"""累计快照行 → 每快照增量行(纯逻辑)。
|
||||
|
||||
rows 每行含 device_id/epoch_id/event/created_at/app_ver/oem/os + 四个累计计数。
|
||||
返回每行含 dims + created_at + 四个增量 d_*(分区首行增量=累计值;负值夹 0)。
|
||||
"""
|
||||
parts: dict[tuple, list[dict]] = defaultdict(list)
|
||||
for r in rows:
|
||||
parts[(r["device_id"], r["epoch_id"], r["event"])].append(r)
|
||||
|
||||
out: list[dict] = []
|
||||
for group in parts.values():
|
||||
group.sort(key=lambda r: (r["created_at"], r.get("id", 0)))
|
||||
prev = {k: 0 for k in _COUNTS}
|
||||
for r in group:
|
||||
deltas = {f"d_{k}": max(0, int(r[k]) - prev[k]) for k in _COUNTS}
|
||||
out.append({
|
||||
"device_id": r["device_id"], "epoch_id": r["epoch_id"], "event": r["event"],
|
||||
"created_at": r["created_at"], "app_ver": r["app_ver"],
|
||||
"oem": r["oem"], "os": r["os"], **deltas,
|
||||
})
|
||||
prev = {k: int(r[k]) for k in _COUNTS}
|
||||
return out
|
||||
|
||||
|
||||
def _cn_day(dt: datetime) -> str:
|
||||
"""created_at(UTC 口径)→ 北京日期字符串 YYYY-MM-DD。naive 当 UTC,tz-aware 直接换算。"""
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
return dt.astimezone(rewards.CN_TZ).date().isoformat()
|
||||
|
||||
|
||||
def _rates(sums: dict) -> dict:
|
||||
"""由四个增量和派生两段率(分母 0 → None)。"""
|
||||
persisted_denom = sums["attempted"]
|
||||
report_denom = sums["delivered"] + sums["drop_undelivered"]
|
||||
return {
|
||||
**sums,
|
||||
"track_success_rate": (
|
||||
(sums["attempted"] - sums["drop_capture"]) / persisted_denom
|
||||
if persisted_denom else None
|
||||
),
|
||||
"report_success_rate": (
|
||||
sums["delivered"] / report_denom if report_denom else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _sum_deltas(deltas: list[dict]) -> dict:
|
||||
return {k: sum(d[f"d_{k}"] for d in deltas) for k in _COUNTS}
|
||||
|
||||
|
||||
def _fetch_rows(db: Session, date_from: datetime, date_to: datetime) -> list[dict]:
|
||||
"""取 [from, to) 区间行 + 每分区在 from 左侧的最后一条基线行(供第一条区间增量做差)。"""
|
||||
cols = (
|
||||
H.id, H.device_id, H.epoch_id, E.event, H.created_at,
|
||||
H.app_ver, H.oem, H.os,
|
||||
E.attempted, E.drop_capture, E.delivered, E.drop_undelivered,
|
||||
)
|
||||
in_range = db.execute(
|
||||
select(*cols).join(E, E.snapshot_id == H.id)
|
||||
.where(H.created_at >= date_from, H.created_at < date_to)
|
||||
).mappings().all()
|
||||
|
||||
# 注:基线子查询无下界扫 from 左侧全量(spec §7 已接受的取舍;量级变大再上物化 rollup)。
|
||||
# 用 max(id) 而非 max(created_at) 选"最新一条":id 严格单调,避免 SQLite 秒级时间戳撞车时选歧义。
|
||||
sub = (
|
||||
select(H.device_id, H.epoch_id, E.event, func.max(H.id).label("max_id"))
|
||||
.join(E, E.snapshot_id == H.id)
|
||||
.where(H.created_at < date_from)
|
||||
.group_by(H.device_id, H.epoch_id, E.event)
|
||||
.subquery()
|
||||
)
|
||||
baseline = db.execute(
|
||||
select(*cols).join(E, E.snapshot_id == H.id).join(
|
||||
sub, sub.c.max_id == H.id
|
||||
)
|
||||
).mappings().all()
|
||||
|
||||
return [dict(r) for r in list(baseline) + list(in_range)]
|
||||
|
||||
|
||||
def _in_range_deltas(db: Session, date_from: datetime, date_to: datetime) -> list[dict]:
|
||||
"""差分后只保留 created_at ∈ [from, to) 的增量(基线行被差分用后丢弃)。
|
||||
|
||||
Python 侧过滤需对齐 tz 口径:SQLite 返回 naive UTC,PG 返回 aware UTC。
|
||||
统一转成 naive UTC 再比较,兼容两种后端。
|
||||
"""
|
||||
def _to_naive_utc(dt: datetime) -> datetime:
|
||||
if dt.tzinfo is not None:
|
||||
return dt.astimezone(UTC).replace(tzinfo=None)
|
||||
return dt
|
||||
|
||||
from_naive = _to_naive_utc(date_from)
|
||||
to_naive = _to_naive_utc(date_to)
|
||||
deltas = diff_snapshots(_fetch_rows(db, date_from, date_to))
|
||||
return [d for d in deltas if from_naive <= _to_naive_utc(d["created_at"]) < to_naive]
|
||||
|
||||
|
||||
def overview(db: Session, date_from: datetime, date_to: datetime) -> dict:
|
||||
deltas = _in_range_deltas(db, date_from, date_to)
|
||||
return _rates(_sum_deltas(deltas))
|
||||
|
||||
|
||||
def trend(db: Session, date_from: datetime, date_to: datetime) -> list[dict]:
|
||||
deltas = _in_range_deltas(db, date_from, date_to)
|
||||
by_day: dict[str, list[dict]] = defaultdict(list)
|
||||
for d in deltas:
|
||||
by_day[_cn_day(d["created_at"])].append(d)
|
||||
return [
|
||||
{"day": day, **_rates(_sum_deltas(items))}
|
||||
for day, items in sorted(by_day.items())
|
||||
]
|
||||
|
||||
|
||||
def breakdown(db: Session, date_from: datetime, date_to: datetime, dim: str) -> list[dict]:
|
||||
if dim not in ("event", "app_ver", "oem"):
|
||||
raise ValueError(f"invalid dim: {dim!r}")
|
||||
deltas = _in_range_deltas(db, date_from, date_to)
|
||||
by_key: dict[str, list[dict]] = defaultdict(list)
|
||||
for d in deltas:
|
||||
by_key[d.get(dim) or "(unknown)"].append(d)
|
||||
rows = [{"key": key, **_rates(_sum_deltas(items))} for key, items in by_key.items()]
|
||||
rows.sort(key=lambda r: (r["report_success_rate"] is None, r["report_success_rate"] or 0.0))
|
||||
return rows
|
||||
@@ -5,17 +5,21 @@
|
||||
- 发起数 = 区间内全部 session(含 started/completed/failed/abandoned),= 流失统计的基数。
|
||||
- 完成数 / 耗时均值 / 分位 = 仅 status==completed 子集(成功跑完才有可比的"领券耗时")。
|
||||
- summary/daily/hourly/total 在全量上算,不受分页;items 为排序后当前页。
|
||||
- 另含 coupon_slot_report(数据源 coupon_claim_record):按 coupon_id「按券成功率」表,见设计 §13。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date as _date, datetime
|
||||
from datetime import UTC, datetime
|
||||
from datetime import date as _date
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy import case, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
from app.models.coupon_state import CouponSession
|
||||
from app.models.coupon_state import CouponClaimRecord, CouponSession
|
||||
from app.models.user import User
|
||||
from app.repositories import ad_ecpm as crud_ecpm
|
||||
from app.repositories.coupon_state import DEFAULT_PLATFORMS, coupon_id_to_platform
|
||||
|
||||
|
||||
def _cn_hour(dt: datetime) -> int:
|
||||
@@ -42,7 +46,47 @@ def _avg(vals: list[int]) -> int | None:
|
||||
return round(sum(vals) / len(vals)) if vals else None
|
||||
|
||||
|
||||
def _session_to_row(r, phone: str | None = None, nickname: str | None = None) -> dict:
|
||||
def _success_rates(rows: list) -> dict:
|
||||
"""平台粒度成功率(见 docs/guides/领券成功率指标-设计与埋点.md §3/§12):
|
||||
|
||||
- sel(s) = 勾选平台(`platforms` 空 → 全领三档 DEFAULT_PLATFORMS);
|
||||
- succ(s) = `platform_success` ∩ sel(至少领到一张的平台);
|
||||
- ② 整单成功率 = #{sel⊆succ 且 sel≠∅} / 发起数;
|
||||
- ③ 点位成功率 = Σ|succ| / Σ|sel|;per_platform[p] = 勾了 p 且成功 / 勾了 p。
|
||||
基数含全部 session(started/completed/failed/abandoned),与「发起数」同基数。
|
||||
"""
|
||||
started = len(rows)
|
||||
full_success = 0
|
||||
point_success = 0
|
||||
point_total = 0
|
||||
per_succ = {p: 0 for p in DEFAULT_PLATFORMS}
|
||||
per_total = {p: 0 for p in DEFAULT_PLATFORMS}
|
||||
for r in rows:
|
||||
sel = set(r.platforms) if r.platforms else set(DEFAULT_PLATFORMS)
|
||||
succ = set(r.platform_success or []) & sel
|
||||
point_success += len(succ)
|
||||
point_total += len(sel)
|
||||
if sel and succ == sel:
|
||||
full_success += 1
|
||||
for p in sel:
|
||||
if p in per_total: # 只统计三档已知平台;未知/非法平台 id 不进 per_platform
|
||||
per_total[p] += 1
|
||||
if p in succ:
|
||||
per_succ[p] += 1
|
||||
return {
|
||||
"full_success_count": full_success,
|
||||
"full_success_rate": round(full_success / started, 4) if started else None,
|
||||
"point_success_count": point_success,
|
||||
"point_total_count": point_total,
|
||||
"point_success_rate": round(point_success / point_total, 4) if point_total else None,
|
||||
"per_platform": {
|
||||
p: (round(per_succ[p] / per_total[p], 4) if per_total[p] else None)
|
||||
for p in DEFAULT_PLATFORMS
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _session_to_row(r, phone: str | None = None, nickname: str | None = None, ad_revenue_yuan: float = 0.0) -> dict:
|
||||
"""CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。"""
|
||||
return {
|
||||
"id": r.id,
|
||||
@@ -61,6 +105,7 @@ def _session_to_row(r, phone: str | None = None, nickname: str | None = None) ->
|
||||
"started_at": r.started_at,
|
||||
"claimed_count": r.claimed_count,
|
||||
"trace_url": r.trace_url,
|
||||
"ad_revenue_yuan": ad_revenue_yuan,
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +129,7 @@ def coupon_data_report(
|
||||
date_to: str,
|
||||
user: str | None = None,
|
||||
app_env: str | None = None,
|
||||
statuses: list[str] | None = None,
|
||||
granularity: str = "day",
|
||||
limit: int = 500,
|
||||
offset: int = 0,
|
||||
@@ -93,6 +139,8 @@ def coupon_data_report(
|
||||
|
||||
- user:手机号/昵称模糊搜(匹配不到任何用户 → 空结果)。
|
||||
- app_env:prod/dev 精确;None=全部。
|
||||
- statuses:领券状态多选(started/completed/failed/abandoned);None/空=全部。整个视图
|
||||
(汇总/成功率/趋势/明细)按选中状态算,与 app_env 同级过滤(方案 A)。
|
||||
- sort:time=发起时刻倒序(默认) / elapsed=全程耗时倒序(None 末尾)。
|
||||
"""
|
||||
by_hour = granularity == "hour"
|
||||
@@ -115,6 +163,8 @@ def coupon_data_report(
|
||||
)
|
||||
if app_env is not None:
|
||||
stmt = stmt.where(CouponSession.app_env == app_env)
|
||||
if statuses:
|
||||
stmt = stmt.where(CouponSession.status.in_(statuses))
|
||||
if user_ids is not None:
|
||||
stmt = stmt.where(CouponSession.user_id.in_(user_ids))
|
||||
rows = list(db.execute(stmt).scalars())
|
||||
@@ -131,6 +181,7 @@ def coupon_data_report(
|
||||
"p50_ms": _percentile(completed_elapsed, 50),
|
||||
"p95_ms": _percentile(completed_elapsed, 95),
|
||||
"p99_ms": _percentile(completed_elapsed, 99),
|
||||
**_success_rates(rows),
|
||||
}
|
||||
|
||||
# ── 按天趋势(柱=发起/完成数,线=平均耗时)──
|
||||
@@ -197,10 +248,11 @@ def coupon_data_report(
|
||||
select(User.id, User.phone, User.nickname).where(User.id.in_(uids))
|
||||
).all()
|
||||
}
|
||||
rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in page])
|
||||
items = []
|
||||
for r in page:
|
||||
phone, nickname = user_map.get(r.user_id, (None, None)) if r.user_id is not None else (None, None)
|
||||
items.append(_session_to_row(r, phone, nickname))
|
||||
items.append(_session_to_row(r, phone, nickname, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0)))
|
||||
|
||||
return {
|
||||
"summary": summary,
|
||||
@@ -222,4 +274,57 @@ def coupon_user_records(db: Session, *, user_id: int, limit: int = 100) -> dict:
|
||||
total = db.execute(
|
||||
select(func.count()).select_from(CouponSession).where(CouponSession.user_id == user_id)
|
||||
).scalar_one()
|
||||
return {"items": [_session_to_row(r) for r in rows], "total": int(total)}
|
||||
rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in rows])
|
||||
return {
|
||||
"items": [_session_to_row(r, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0)) for r in rows],
|
||||
"total": int(total),
|
||||
}
|
||||
|
||||
|
||||
_SLOT_OK = ("success", "already_claimed")
|
||||
_SLOT_TRIED = ("success", "already_claimed", "failed")
|
||||
|
||||
|
||||
def coupon_slot_report(
|
||||
db: Session, *, date_from: str, date_to: str, app_env: str | None = None
|
||||
) -> dict:
|
||||
"""按 coupon_id(具体券)聚合成功率(见 docs/guides/领券成功率指标-设计与埋点.md §13)。
|
||||
|
||||
数据源 coupon_claim_record(粒度=设备-天,唯一键 device+coupon+day)。
|
||||
- 尝试 = status ∈ {success, already_claimed, failed}(skipped 排除);
|
||||
- 成功 = status ∈ {success, already_claimed};成功率 = 成功/尝试;
|
||||
- claim_date 区间 + app_env(None=全部)过滤;按 tried 倒序返回。
|
||||
"""
|
||||
d_from = _date.fromisoformat(date_from)
|
||||
d_to = _date.fromisoformat(date_to)
|
||||
ok = case((CouponClaimRecord.status.in_(_SLOT_OK), 1), else_=0)
|
||||
stmt = (
|
||||
select(
|
||||
CouponClaimRecord.coupon_id,
|
||||
func.max(CouponClaimRecord.coupon_name).label("coupon_name"),
|
||||
func.count().label("tried"),
|
||||
func.sum(ok).label("succeeded"),
|
||||
)
|
||||
.where(
|
||||
CouponClaimRecord.claim_date >= d_from,
|
||||
CouponClaimRecord.claim_date <= d_to,
|
||||
CouponClaimRecord.status.in_(_SLOT_TRIED),
|
||||
)
|
||||
.group_by(CouponClaimRecord.coupon_id)
|
||||
)
|
||||
if app_env is not None:
|
||||
stmt = stmt.where(CouponClaimRecord.app_env == app_env)
|
||||
items = []
|
||||
for coupon_id, coupon_name, tried, succeeded in db.execute(stmt).all():
|
||||
tried = int(tried or 0)
|
||||
succeeded = int(succeeded or 0)
|
||||
items.append({
|
||||
"coupon_id": coupon_id,
|
||||
"coupon_name": coupon_name,
|
||||
"platform": coupon_id_to_platform(coupon_id),
|
||||
"tried": tried,
|
||||
"succeeded": succeeded,
|
||||
"success_rate": round(succeeded / tried, 4) if tried else None,
|
||||
})
|
||||
items.sort(key=lambda x: (-x["tried"], x["coupon_id"]))
|
||||
return {"items": items}
|
||||
|
||||
@@ -51,7 +51,27 @@ def _yuan_to_cents(v: object) -> int | None:
|
||||
|
||||
def _ts_to_dt(ts: object) -> datetime | None:
|
||||
"""秒级时间戳 → tz-aware UTC datetime(绝对时刻,前端按北京展示)。"""
|
||||
if not ts:
|
||||
if ts is None:
|
||||
return None
|
||||
if isinstance(ts, datetime):
|
||||
return ts if ts.tzinfo else ts.replace(tzinfo=_BJ_TZ).astimezone(timezone.utc)
|
||||
s = str(ts).strip()
|
||||
if not s or s.lower() == "null":
|
||||
return None
|
||||
try:
|
||||
seconds = float(Decimal(s))
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
if seconds == 0:
|
||||
return None
|
||||
# 美团文档是秒级时间戳,这里顺手兼容毫秒/微秒,避免上游格式变化导致时间再次落空。
|
||||
if abs(seconds) > 10_000_000_000_000:
|
||||
seconds /= 1_000_000
|
||||
elif abs(seconds) > 10_000_000_000:
|
||||
seconds /= 1_000
|
||||
try:
|
||||
return datetime.fromtimestamp(seconds, tz=timezone.utc)
|
||||
except (OverflowError, OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
@@ -83,10 +103,6 @@ def _pick(row: dict[str, Any], *keys: str) -> Any:
|
||||
if key in row and row[key] is not None:
|
||||
return row[key]
|
||||
return None
|
||||
try:
|
||||
return datetime.fromtimestamp(int(ts), tz=timezone.utc)
|
||||
except (ValueError, OSError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
# ───────────── 群 ─────────────
|
||||
|
||||
@@ -11,6 +11,7 @@ from zoneinfo import ZoneInfo
|
||||
from sqlalchemy import Select, asc, case, desc, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.admin.repositories.stats import COMPARE_START_EVENT, COUPON_START_EVENT
|
||||
from app.core import rewards
|
||||
from app.core.config import settings
|
||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||
@@ -18,12 +19,23 @@ from app.models.ad_reward import AdRewardRecord
|
||||
from app.models.admin import AdminAuditLog
|
||||
from app.models.analytics_event import AnalyticsEvent
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.coupon_state import CouponPromptEngagement
|
||||
from app.models.device import DeviceLiveness
|
||||
from app.models.feedback import Feedback
|
||||
from app.models.onboarding import OnboardingCompletion
|
||||
from app.models.price_report import PriceReport
|
||||
from app.models.user import User
|
||||
from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder
|
||||
from app.models.wallet import (
|
||||
CashTransaction,
|
||||
CoinAccount,
|
||||
CoinTransaction,
|
||||
InviteCashTransaction,
|
||||
WithdrawOrder,
|
||||
)
|
||||
from app.repositories import ad_ecpm
|
||||
|
||||
# 「最近活跃」计入的行为事件(与大盘 DAU/留存活跃口径一致:开始比价 + 开始领券)
|
||||
_ACTIVE_EVENTS = (COMPARE_START_EVENT, COUPON_START_EVENT)
|
||||
|
||||
# 折算成可提现现金时,非广告金币来源的排除集(广告单独统计、人工调整不算"赚取")
|
||||
_NON_TASK_BIZ_TYPES = ("reward_video", "feed_ad_reward", "admin_grant", "admin_deduct")
|
||||
@@ -76,6 +88,86 @@ def offset_paginate(
|
||||
return items, next_cursor, total
|
||||
|
||||
|
||||
def _last_active_parts():
|
||||
"""「最近活跃」的两个按 user_id 预聚合派生表(最近开始比价/领券事件、最近领券发起)。
|
||||
|
||||
活跃口径与大盘 DAU/留存一致(2026-07-05 产品定:进入 App≈登录 last_login_at +
|
||||
发起比价 real_compare_start + 发起领券 real_coupon_start/claim_started)。
|
||||
用 LEFT JOIN 预聚合而非相关标量子查询:后者在 PG 上对 users 每行各跑一个 SubPlan
|
||||
(排序键、range 筛选、offset_paginate 的 count 三处叠加),埋点表大了会拖垮列表接口;
|
||||
预聚合借 analytics_event.event 索引只扫两类 start 事件,每次查询聚合一次。
|
||||
"""
|
||||
ev_agg = (
|
||||
select(
|
||||
AnalyticsEvent.user_id.label("user_id"),
|
||||
func.max(AnalyticsEvent.created_at).label("last_at"),
|
||||
)
|
||||
.where(
|
||||
AnalyticsEvent.user_id.is_not(None),
|
||||
AnalyticsEvent.event.in_(_ACTIVE_EVENTS),
|
||||
)
|
||||
.group_by(AnalyticsEvent.user_id)
|
||||
.subquery()
|
||||
)
|
||||
eng_agg = (
|
||||
select(
|
||||
CouponPromptEngagement.user_id.label("user_id"),
|
||||
func.max(CouponPromptEngagement.created_at).label("last_at"),
|
||||
)
|
||||
.where(
|
||||
CouponPromptEngagement.user_id.is_not(None),
|
||||
CouponPromptEngagement.engage_type == "claim_started",
|
||||
)
|
||||
.group_by(CouponPromptEngagement.user_id)
|
||||
.subquery()
|
||||
)
|
||||
return ev_agg, eng_agg
|
||||
|
||||
|
||||
def _norm_utc(dt: datetime | None) -> datetime | None:
|
||||
"""naive 视为 UTC 补 tzinfo(SQLite 读回 naive、PG 读回 aware,混着 max() 会 TypeError)。"""
|
||||
if dt is None:
|
||||
return None
|
||||
return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _attach_last_active(db: Session, users: list[User]) -> None:
|
||||
"""给本页用户瞬态挂 last_active_at(非 DB 列,供 AdminUserListItem from_attributes 读)。
|
||||
|
||||
口径同 [_last_active_expr];按本页 user_id 批量两次 GROUP BY,防 N+1。
|
||||
"""
|
||||
uids = [u.id for u in users]
|
||||
if not uids:
|
||||
return
|
||||
ev_map = dict(
|
||||
db.execute(
|
||||
select(AnalyticsEvent.user_id, func.max(AnalyticsEvent.created_at))
|
||||
.where(
|
||||
AnalyticsEvent.user_id.in_(uids),
|
||||
AnalyticsEvent.event.in_(_ACTIVE_EVENTS),
|
||||
)
|
||||
.group_by(AnalyticsEvent.user_id)
|
||||
).all()
|
||||
)
|
||||
eng_map = dict(
|
||||
db.execute(
|
||||
select(CouponPromptEngagement.user_id, func.max(CouponPromptEngagement.created_at))
|
||||
.where(
|
||||
CouponPromptEngagement.user_id.in_(uids),
|
||||
CouponPromptEngagement.engage_type == "claim_started",
|
||||
)
|
||||
.group_by(CouponPromptEngagement.user_id)
|
||||
).all()
|
||||
)
|
||||
for u in users:
|
||||
candidates = [
|
||||
_norm_utc(u.last_login_at),
|
||||
_norm_utc(ev_map.get(u.id)),
|
||||
_norm_utc(eng_map.get(u.id)),
|
||||
]
|
||||
u.last_active_at = max((c for c in candidates if c is not None), default=None)
|
||||
|
||||
|
||||
def list_users(
|
||||
db: Session,
|
||||
*,
|
||||
@@ -87,16 +179,34 @@ def list_users(
|
||||
created_to: datetime | None = None,
|
||||
last_login_from: datetime | None = None,
|
||||
last_login_to: datetime | None = None,
|
||||
last_active_from: datetime | None = None,
|
||||
last_active_to: datetime | None = None,
|
||||
sort_by: str = "id",
|
||||
sort_order: str = "desc",
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[User], int | None, int]:
|
||||
"""用户列表(admin 全量)。支持手机号前缀 / 渠道 / 状态 / 昵称模糊 / 注册·最近登录时间范围筛选,
|
||||
按 id·注册时间·最近登录排序。**offset 分页**(cursor=offset):任意列排序下游标语义统一,
|
||||
"""用户列表(admin 全量)。支持手机号前缀 / 渠道 / 状态 / 昵称模糊 / 注册·最近登录·最近活跃
|
||||
时间范围筛选,按 id·注册时间·最近登录·最近活跃排序;每页附带计算列 last_active_at
|
||||
(口径见 [_last_active_expr])。**offset 分页**(cursor=offset):任意列排序下游标语义统一,
|
||||
代价是翻页期间数据变动可能错位一条——admin 低频场景可接受(同 [list_all_withdraw_orders])。
|
||||
日期入参统一转 tz-aware UTC 比较(列为 timestamptz,见 _as_utc)。"""
|
||||
stmt = select(User)
|
||||
# 最近活跃 = max(最近登录, 最近行为事件, 最近领券发起)。PG 用 GREATEST;SQLite 标量 max()
|
||||
# 任一参数 NULL 即返回 NULL,故 LEFT JOIN 未命中侧 coalesce 到 last_login_at 兜底
|
||||
# (注册即登录,该列恒非空)。派生表 1:1(按 user_id 聚合),outerjoin 不会放大行数,
|
||||
# offset_paginate 的 count 不受影响。
|
||||
ev_agg, eng_agg = _last_active_parts()
|
||||
greatest = func.greatest if db.get_bind().dialect.name == "postgresql" else func.max
|
||||
last_active = greatest(
|
||||
User.last_login_at,
|
||||
func.coalesce(ev_agg.c.last_at, User.last_login_at),
|
||||
func.coalesce(eng_agg.c.last_at, User.last_login_at),
|
||||
)
|
||||
stmt = (
|
||||
select(User)
|
||||
.outerjoin(ev_agg, ev_agg.c.user_id == User.id)
|
||||
.outerjoin(eng_agg, eng_agg.c.user_id == User.id)
|
||||
)
|
||||
if phone:
|
||||
stmt = stmt.where(User.phone.like(f"{phone}%")) # 前缀匹配
|
||||
if register_channel:
|
||||
@@ -113,16 +223,25 @@ def list_users(
|
||||
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))
|
||||
if last_active_from is not None:
|
||||
stmt = stmt.where(last_active >= _as_utc(last_active_from))
|
||||
if last_active_to is not None:
|
||||
stmt = stmt.where(last_active <= _as_utc(last_active_to))
|
||||
|
||||
sort_cols = {
|
||||
"id": User.id,
|
||||
"created_at": User.created_at,
|
||||
"last_login_at": User.last_login_at,
|
||||
"last_active_at": last_active,
|
||||
}
|
||||
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)
|
||||
items, next_cursor, total = offset_paginate(
|
||||
db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor
|
||||
)
|
||||
_attach_last_active(db, items)
|
||||
return items, next_cursor, total
|
||||
|
||||
|
||||
def _attach_user_info(db: Session, records: list[ComparisonRecord | Feedback | PriceReport]) -> None:
|
||||
@@ -148,11 +267,14 @@ def list_comparison_records(
|
||||
phone: str | None = None,
|
||||
status: str | None = None,
|
||||
business_type: str | None = None,
|
||||
store: str | None = None,
|
||||
product: str | None = None,
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[ComparisonRecord], int | None, int]:
|
||||
"""admin 比价记录列表(debug)。按 user_id 精确 或 phone 前缀定位用户 + status/业务类型筛,
|
||||
offset 分页(创建时间倒序、id 兜底)。join User 取 phone/nickname 瞬态挂记录上。"""
|
||||
store(店名)/product(商品名)子串模糊匹配,offset 分页(创建时间倒序、id 兜底)。
|
||||
join User 取 phone/nickname 瞬态挂记录上。"""
|
||||
stmt = select(ComparisonRecord)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(ComparisonRecord.user_id == user_id)
|
||||
@@ -166,12 +288,22 @@ def list_comparison_records(
|
||||
stmt = stmt.where(ComparisonRecord.status == status)
|
||||
if business_type:
|
||||
stmt = stmt.where(ComparisonRecord.business_type == business_type)
|
||||
if store:
|
||||
stmt = stmt.where(ComparisonRecord.store_name.like(f"%{store}%"))
|
||||
if product:
|
||||
# 商品名搜 product_names 派生文本列(非 items JSON:SQLite 下 JSON 中文被转义无法直接 LIKE)。
|
||||
stmt = stmt.where(ComparisonRecord.product_names.like(f"%{product}%"))
|
||||
items, next_cursor, total = offset_paginate(
|
||||
db, stmt,
|
||||
(desc(ComparisonRecord.created_at), desc(ComparisonRecord.id)),
|
||||
limit=limit, cursor=cursor,
|
||||
)
|
||||
_attach_user_info(db, items)
|
||||
# 「本次比价看广告的预估收益」:按本页 trace_id 一次性聚合(同 _attach_user_info 逐页范式)。
|
||||
# ad_revenue_yuan 非 ORM 列,仅瞬态挂实例上供 AdminComparisonListItem(from_attributes)读出。
|
||||
rev = ad_ecpm.revenue_yuan_by_trace(db, [it.trace_id for it in items])
|
||||
for it in items:
|
||||
it.ad_revenue_yuan = rev.get(it.trace_id, 0.0)
|
||||
return items, next_cursor, total
|
||||
|
||||
|
||||
@@ -726,26 +858,19 @@ def withdraw_risk_flags(
|
||||
return flags, score
|
||||
|
||||
|
||||
def withdraw_ledger_check(db: Session) -> dict:
|
||||
cash_balance_total = int(
|
||||
db.execute(select(func.coalesce(func.sum(CoinAccount.cash_balance_cents), 0))).scalar_one()
|
||||
)
|
||||
cash_txn_total = int(
|
||||
db.execute(select(func.coalesce(func.sum(CashTransaction.amount_cents), 0))).scalar_one()
|
||||
)
|
||||
def _check_withdraw_ledger_side(
|
||||
orders: list[WithdrawOrder], txns: list, *, withdraw_biz: str, refund_biz: str
|
||||
) -> dict:
|
||||
"""对某一本账(普通现金 / 邀请奖励金)做提现单 ↔ 流水的交叉校验。
|
||||
|
||||
orders = list(db.execute(select(WithdrawOrder)).scalars().all())
|
||||
cash_txns = list(
|
||||
db.execute(
|
||||
select(CashTransaction).where(
|
||||
CashTransaction.biz_type.in_(("withdraw", "withdraw_refund"))
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
withdraw_refs = {txn.ref_id for txn in cash_txns if txn.biz_type == "withdraw"}
|
||||
orders 已按 source 过滤到本账;txns 是本账流水表里 withdraw_biz/refund_biz 两类流水。
|
||||
规则:每单发起应有一条扣款流水(ref_id=out_bill_no);失败/拒绝单应有且仅一条退款流水;
|
||||
非退款终态不应出现退款流水。四个计数全为 0 即本账自洽。
|
||||
"""
|
||||
withdraw_refs = {txn.ref_id for txn in txns if txn.biz_type == withdraw_biz}
|
||||
refund_counts: dict[str, int] = {}
|
||||
for txn in cash_txns:
|
||||
if txn.biz_type == "withdraw_refund" and txn.ref_id:
|
||||
for txn in txns:
|
||||
if txn.biz_type == refund_biz and txn.ref_id:
|
||||
refund_counts[txn.ref_id] = refund_counts.get(txn.ref_id, 0) + 1
|
||||
|
||||
missing_withdraw = 0
|
||||
@@ -760,24 +885,95 @@ def withdraw_ledger_check(db: Session) -> dict:
|
||||
if has_refund and order.status not in {"failed", "rejected"}:
|
||||
refund_on_non_terminal += 1
|
||||
|
||||
duplicate_refund = sum(1 for count in refund_counts.values() if count > 1)
|
||||
diff = cash_balance_total - cash_txn_total
|
||||
return {
|
||||
"missing_withdraw": missing_withdraw,
|
||||
"missing_refund": missing_refund,
|
||||
"duplicate_refund": sum(1 for count in refund_counts.values() if count > 1),
|
||||
"refund_on_non_terminal": refund_on_non_terminal,
|
||||
}
|
||||
|
||||
|
||||
def withdraw_ledger_check(db: Session) -> dict:
|
||||
"""现金账本校验:两本物理隔离的账各自对账(产品红线:coin_cash / invite_cash 不串)。
|
||||
|
||||
普通现金:CoinAccount.cash_balance_cents ↔ cash_transaction(withdraw/withdraw_refund);
|
||||
邀请奖励金:CoinAccount.invite_cash_balance_cents ↔ invite_cash_transaction
|
||||
(invite_withdraw/invite_withdraw_refund)。
|
||||
提现单按 source 分流到对应账核对——邀请提现的流水写在 invite_cash_transaction 表,
|
||||
绝不能拿去和普通现金流水比(否则每笔邀请提现单都会被误报「缺扣款/缺退款流水」)。
|
||||
分流口径与 create_withdraw 一致:仅 source==invite_cash 走邀请账,其余(含历史空值)归普通现金。
|
||||
"""
|
||||
orders = list(db.execute(select(WithdrawOrder)).scalars().all())
|
||||
coin_orders = [o for o in orders if o.source != "invite_cash"]
|
||||
invite_orders = [o for o in orders if o.source == "invite_cash"]
|
||||
|
||||
# —— 普通现金账(coin_cash) ——
|
||||
cash_balance_total = int(
|
||||
db.execute(select(func.coalesce(func.sum(CoinAccount.cash_balance_cents), 0))).scalar_one()
|
||||
)
|
||||
cash_txn_total = int(
|
||||
db.execute(select(func.coalesce(func.sum(CashTransaction.amount_cents), 0))).scalar_one()
|
||||
)
|
||||
cash_txns = list(
|
||||
db.execute(
|
||||
select(CashTransaction).where(
|
||||
CashTransaction.biz_type.in_(("withdraw", "withdraw_refund"))
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
coin = _check_withdraw_ledger_side(
|
||||
coin_orders, cash_txns, withdraw_biz="withdraw", refund_biz="withdraw_refund"
|
||||
)
|
||||
cash_diff = cash_balance_total - cash_txn_total
|
||||
|
||||
# —— 邀请奖励金账(invite_cash,独立账户 + 独立流水表) ——
|
||||
invite_balance_total = int(
|
||||
db.execute(
|
||||
select(func.coalesce(func.sum(CoinAccount.invite_cash_balance_cents), 0))
|
||||
).scalar_one()
|
||||
)
|
||||
invite_txn_total = int(
|
||||
db.execute(
|
||||
select(func.coalesce(func.sum(InviteCashTransaction.amount_cents), 0))
|
||||
).scalar_one()
|
||||
)
|
||||
invite_txns = list(
|
||||
db.execute(
|
||||
select(InviteCashTransaction).where(
|
||||
InviteCashTransaction.biz_type.in_(("invite_withdraw", "invite_withdraw_refund"))
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
invite = _check_withdraw_ledger_side(
|
||||
invite_orders, invite_txns,
|
||||
withdraw_biz="invite_withdraw", refund_biz="invite_withdraw_refund",
|
||||
)
|
||||
invite_diff = invite_balance_total - invite_txn_total
|
||||
|
||||
ok = (
|
||||
diff == 0
|
||||
and missing_withdraw == 0
|
||||
and missing_refund == 0
|
||||
and duplicate_refund == 0
|
||||
and refund_on_non_terminal == 0
|
||||
cash_diff == 0
|
||||
and invite_diff == 0
|
||||
and all(v == 0 for v in coin.values())
|
||||
and all(v == 0 for v in invite.values())
|
||||
)
|
||||
return {
|
||||
"ok": ok,
|
||||
# 普通现金账(coin_cash:金币兑换的现金)
|
||||
"cash_balance_total_cents": cash_balance_total,
|
||||
"cash_transaction_total_cents": cash_txn_total,
|
||||
"balance_diff_cents": diff,
|
||||
"missing_withdraw_txn_count": missing_withdraw,
|
||||
"missing_refund_txn_count": missing_refund,
|
||||
"duplicate_refund_txn_count": duplicate_refund,
|
||||
"refund_txn_on_non_terminal_count": refund_on_non_terminal,
|
||||
"balance_diff_cents": cash_diff,
|
||||
"missing_withdraw_txn_count": coin["missing_withdraw"],
|
||||
"missing_refund_txn_count": coin["missing_refund"],
|
||||
"duplicate_refund_txn_count": coin["duplicate_refund"],
|
||||
"refund_txn_on_non_terminal_count": coin["refund_on_non_terminal"],
|
||||
# 邀请奖励金账(invite_cash:与普通现金物理隔离,各自对账)
|
||||
"invite_cash_balance_total_cents": invite_balance_total,
|
||||
"invite_cash_transaction_total_cents": invite_txn_total,
|
||||
"invite_balance_diff_cents": invite_diff,
|
||||
"invite_missing_withdraw_txn_count": invite["missing_withdraw"],
|
||||
"invite_missing_refund_txn_count": invite["missing_refund"],
|
||||
"invite_duplicate_refund_txn_count": invite["duplicate_refund"],
|
||||
"invite_refund_txn_on_non_terminal_count": invite["refund_on_non_terminal"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
+222
-74
@@ -5,17 +5,23 @@ user.last_login_at / comparison_record.status / withdraw_order.status)要加索
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import case, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.admin.repositories.coupon_data import _percentile
|
||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||
from app.models.ad_reward import AdRewardRecord
|
||||
from app.models.analytics_event import AnalyticsEvent
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.coupon_state import CouponPromptEngagement
|
||||
from app.models.coupon_state import (
|
||||
CouponClaimRecord,
|
||||
CouponPromptEngagement,
|
||||
CouponSession,
|
||||
)
|
||||
from app.models.cps_order import CpsOrder
|
||||
from app.models.feedback import Feedback
|
||||
from app.models.savings import SavingsRecord
|
||||
@@ -24,11 +30,17 @@ from app.models.user import User
|
||||
from app.models.wallet import CoinTransaction, WithdrawOrder
|
||||
|
||||
_BEIJING = timezone(timedelta(hours=8))
|
||||
COUPON_REWARD_BIZ_TYPES = ("reward_video", "ad_reward", "coupon", "coupon_reward")
|
||||
REWARD_VIDEO_BIZ_TYPES = ("reward_video", "ad_reward")
|
||||
# 领券/比价奖励金币的真实来源是信息流广告发奖(ad_feed_reward_record,按 feed_scene 分场景);
|
||||
# coin_transaction 里只有扁平的 feed_ad_reward、biz_type 不分 coupon/comparison,故这俩桶历史从未
|
||||
# 被写入,仅留作未来兜底,实际金额在下方按 feed_scene 汇总 ad_feed_reward_record 得出。reward_video/
|
||||
# ad_reward 是激励视频,单独成桶、不再混进领券奖励(历史误并会把激励视频金币双计进领券)。
|
||||
COUPON_REWARD_BIZ_TYPES = ("coupon", "coupon_reward")
|
||||
COMPARISON_REWARD_BIZ_TYPES = ("comparison", "compare_reward", "comparison_reward")
|
||||
EXCLUDED_REWARD_BIZ_TYPES = ("invite_inviter", "invite_invitee", "admin_grant")
|
||||
UNCLASSIFIED_FEED_BIZ_TYPES = ("feed_ad_reward",)
|
||||
REGULAR_TASK_EXCLUDED_BIZ_TYPES = (
|
||||
*REWARD_VIDEO_BIZ_TYPES,
|
||||
*COUPON_REWARD_BIZ_TYPES,
|
||||
*COMPARISON_REWARD_BIZ_TYPES,
|
||||
*EXCLUDED_REWARD_BIZ_TYPES,
|
||||
@@ -55,29 +67,10 @@ def _beijing_today_start_utc() -> datetime:
|
||||
def today_dau(db: Session) -> int:
|
||||
"""今日活跃用户数(DAU):登录 + 开始比价 + 开始领券,按用户去重。
|
||||
|
||||
广告收益报表复用这个函数;历史窗口 DAU 由 dashboard_overview 的 period 口径另算。
|
||||
= period_active_dau(今天, 今天);历史 / 多天窗口用 period_active_dau 传区间(广告收益报表复用)。
|
||||
"""
|
||||
today_bj = datetime.now(_BEIJING).date()
|
||||
today_start = _beijing_today_start_utc()
|
||||
tomorrow_start = today_start + timedelta(days=1)
|
||||
login_user_ids = _id_set(
|
||||
db,
|
||||
select(User.id).where(User.last_login_at >= today_start, User.last_login_at < tomorrow_start),
|
||||
)
|
||||
compare_start_user_ids = _event_user_ids(
|
||||
db, (COMPARE_START_EVENT,), today_start, tomorrow_start
|
||||
)
|
||||
coupon_event_user_ids = _event_user_ids(
|
||||
db, (COUPON_START_EVENT,), today_start, tomorrow_start
|
||||
)
|
||||
coupon_claim_user_ids = _id_set(
|
||||
db,
|
||||
select(CouponPromptEngagement.user_id).where(
|
||||
CouponPromptEngagement.engage_date == today_bj,
|
||||
CouponPromptEngagement.engage_type == "claim_started",
|
||||
),
|
||||
)
|
||||
return len(login_user_ids | compare_start_user_ids | coupon_event_user_ids | coupon_claim_user_ids)
|
||||
return period_active_dau(db, today_bj, today_bj)
|
||||
|
||||
|
||||
def _default_period_end() -> date:
|
||||
@@ -134,6 +127,56 @@ def _event_user_ids(
|
||||
)
|
||||
|
||||
|
||||
def _period_active_user_ids(
|
||||
db: Session,
|
||||
*,
|
||||
start_utc: datetime,
|
||||
end_utc: datetime,
|
||||
period_from: date,
|
||||
period_to: date,
|
||||
) -> set[int]:
|
||||
"""区间去重活跃用户 id 集合:登录(last_login_at)+ 开始比价(real_compare_start)+
|
||||
开始领券(real_coupon_start / claim_started)。
|
||||
|
||||
user / analytics_event 用 UTC aware 边界 [start_utc, end_utc);coupon_prompt_engagement
|
||||
的 engage_date 是北京自然日 date 列,用 [period_from, period_to] 闭区间。今日 / 历史 / 多天通用。
|
||||
"""
|
||||
login_user_ids = _id_set(
|
||||
db,
|
||||
select(User.id).where(User.last_login_at >= start_utc, User.last_login_at < end_utc),
|
||||
)
|
||||
compare_start_user_ids = _event_user_ids(db, (COMPARE_START_EVENT,), start_utc, end_utc)
|
||||
coupon_event_user_ids = _event_user_ids(db, (COUPON_START_EVENT,), start_utc, end_utc)
|
||||
coupon_claim_user_ids = _id_set(
|
||||
db,
|
||||
select(CouponPromptEngagement.user_id).where(
|
||||
CouponPromptEngagement.engage_date >= period_from,
|
||||
CouponPromptEngagement.engage_date <= period_to,
|
||||
CouponPromptEngagement.engage_type == "claim_started",
|
||||
),
|
||||
)
|
||||
return login_user_ids | compare_start_user_ids | coupon_event_user_ids | coupon_claim_user_ids
|
||||
|
||||
|
||||
def period_active_dau(db: Session, date_from: date, date_to: date) -> int:
|
||||
"""任意北京自然日区间 [date_from, date_to] 的去重活跃用户数。
|
||||
|
||||
与数据大盘 period.users.active 同口径(登录 + 开始比价 + 开始领券);广告收益报表按所选
|
||||
日期区间(含今日)复用,ARPU = 区间预估收益 ÷ 本数。全局口径,不随用户 / 类型 / 场景筛选变化。
|
||||
"""
|
||||
period_from, period_to = _normalize_period(date_from, date_to)
|
||||
start_utc, end_utc, _start_local, _end_local = _period_bounds(period_from, period_to)
|
||||
return len(
|
||||
_period_active_user_ids(
|
||||
db,
|
||||
start_utc=start_utc,
|
||||
end_utc=end_utc,
|
||||
period_from=period_from,
|
||||
period_to=period_to,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _commission_rate_percent(raw: str | None) -> Decimal | None:
|
||||
"""美团 commissionRate 原值: "300"=3%, "10"=0.1%;也兼容 "3%"。"""
|
||||
if raw is None:
|
||||
@@ -261,31 +304,19 @@ def dashboard_overview(
|
||||
period_new_user_ids = _user_id_set(
|
||||
select(User.id).where(User.created_at >= start_utc, User.created_at < end_utc)
|
||||
)
|
||||
login_user_ids = _user_id_set(
|
||||
select(User.id).where(User.last_login_at >= start_utc, User.last_login_at < end_utc)
|
||||
)
|
||||
compare_start_user_ids = _event_user_ids(
|
||||
db, (COMPARE_START_EVENT,), start_utc, end_utc
|
||||
)
|
||||
coupon_event_user_ids = _event_user_ids(
|
||||
db, (COUPON_START_EVENT,), start_utc, end_utc
|
||||
)
|
||||
coupon_claim_user_ids = _user_id_set(
|
||||
select(CouponPromptEngagement.user_id).where(
|
||||
CouponPromptEngagement.engage_date >= period_from,
|
||||
CouponPromptEngagement.engage_date <= period_to,
|
||||
CouponPromptEngagement.engage_type == "claim_started",
|
||||
)
|
||||
)
|
||||
period_active_user_ids = (
|
||||
login_user_ids | compare_start_user_ids | coupon_event_user_ids | coupon_claim_user_ids
|
||||
)
|
||||
period_retained_new_user_ids = period_new_user_ids & period_active_user_ids
|
||||
period_retention_rate = (
|
||||
round(len(period_retained_new_user_ids) / len(period_new_user_ids), 4)
|
||||
if period_new_user_ids
|
||||
else None
|
||||
period_active_user_ids = _period_active_user_ids(
|
||||
db,
|
||||
start_utc=start_utc,
|
||||
end_utc=end_utc,
|
||||
period_from=period_from,
|
||||
period_to=period_to,
|
||||
)
|
||||
# 留存口径(2026-07-05 产品改):次日留存——窗口内每天 D,取 **D-1 日(前日)新增**用户,
|
||||
# 统计其 D 日活跃(登录/开始比价/开始领券)比例,逐日累加。默认窗口=昨日单天,即
|
||||
# 「前日新增用户的昨日留存」。原口径(窗口内新增∩窗口内活跃)在单日窗口下≈100% 无意义
|
||||
# (注册即登录,当天新增必然当天活跃)。逐日 cohort 在下方 trend 循环内顺带累计。
|
||||
retention_cohort_total = 0
|
||||
retention_retained_total = 0
|
||||
trend_points: list[dict] = []
|
||||
for cur_date in _date_range(period_from, period_to):
|
||||
day_start_utc, day_end_utc, day_start_local, day_end_local = _period_bounds(
|
||||
@@ -295,33 +326,26 @@ def dashboard_overview(
|
||||
ComparisonRecord.created_at >= day_start_local,
|
||||
ComparisonRecord.created_at < day_end_local,
|
||||
)
|
||||
daily_login_user_ids = _user_id_set(
|
||||
daily_active_user_ids = _period_active_user_ids(
|
||||
db,
|
||||
start_utc=day_start_utc,
|
||||
end_utc=day_end_utc,
|
||||
period_from=cur_date,
|
||||
period_to=cur_date,
|
||||
)
|
||||
# 次日留存:cohort = 前一日(D-1)新增用户,留存 = 其中当日(D)活跃者(口径见上)。
|
||||
cohort_ids = _user_id_set(
|
||||
select(User.id).where(
|
||||
User.last_login_at >= day_start_utc,
|
||||
User.last_login_at < day_end_utc,
|
||||
)
|
||||
)
|
||||
daily_compare_start_user_ids = _event_user_ids(
|
||||
db, (COMPARE_START_EVENT,), day_start_utc, day_end_utc
|
||||
)
|
||||
daily_coupon_event_user_ids = _event_user_ids(
|
||||
db, (COUPON_START_EVENT,), day_start_utc, day_end_utc
|
||||
)
|
||||
daily_coupon_claim_user_ids = _user_id_set(
|
||||
select(CouponPromptEngagement.user_id).where(
|
||||
CouponPromptEngagement.engage_date == cur_date,
|
||||
CouponPromptEngagement.engage_type == "claim_started",
|
||||
User.created_at >= day_start_utc - timedelta(days=1),
|
||||
User.created_at < day_end_utc - timedelta(days=1),
|
||||
)
|
||||
)
|
||||
retention_cohort_total += len(cohort_ids)
|
||||
retention_retained_total += len(cohort_ids & daily_active_user_ids)
|
||||
trend_points.append(
|
||||
{
|
||||
"date": cur_date,
|
||||
"active_users": len(
|
||||
daily_login_user_ids
|
||||
| daily_compare_start_user_ids
|
||||
| daily_coupon_event_user_ids
|
||||
| daily_coupon_claim_user_ids
|
||||
),
|
||||
"active_users": len(daily_active_user_ids),
|
||||
"new_users": _count(
|
||||
User,
|
||||
User.created_at >= day_start_utc,
|
||||
@@ -330,6 +354,11 @@ def dashboard_overview(
|
||||
"comparisons": _count(ComparisonRecord, *daily_comparison_conds),
|
||||
}
|
||||
)
|
||||
period_retention_rate = (
|
||||
round(retention_retained_total / retention_cohort_total, 4)
|
||||
if retention_cohort_total
|
||||
else None
|
||||
)
|
||||
|
||||
period_coin_conds = (
|
||||
CoinTransaction.created_at >= start_local,
|
||||
@@ -339,7 +368,7 @@ def dashboard_overview(
|
||||
period_reward_video_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.in_(("reward_video", "ad_reward")),
|
||||
CoinTransaction.biz_type.in_(REWARD_VIDEO_BIZ_TYPES),
|
||||
)
|
||||
period_feed_ad_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
@@ -361,15 +390,29 @@ def dashboard_overview(
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.like("task_%"),
|
||||
)
|
||||
# 领券/比价奖励金币 = biz_type 桶(历史空,兜底)+ 该场景信息流广告实发金币
|
||||
# (ad_feed_reward_record.feed_scene,granted;reward_date 是北京日期串,与 period 同自然日窗口)。
|
||||
period_coupon_reward_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.in_(COUPON_REWARD_BIZ_TYPES),
|
||||
) + _sum(
|
||||
AdFeedRewardRecord.coin,
|
||||
AdFeedRewardRecord.status == "granted",
|
||||
AdFeedRewardRecord.feed_scene == "coupon",
|
||||
AdFeedRewardRecord.reward_date >= period_from.isoformat(),
|
||||
AdFeedRewardRecord.reward_date <= period_to.isoformat(),
|
||||
)
|
||||
period_comparison_reward_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.in_(COMPARISON_REWARD_BIZ_TYPES),
|
||||
) + _sum(
|
||||
AdFeedRewardRecord.coin,
|
||||
AdFeedRewardRecord.status == "granted",
|
||||
AdFeedRewardRecord.feed_scene == "comparison",
|
||||
AdFeedRewardRecord.reward_date >= period_from.isoformat(),
|
||||
AdFeedRewardRecord.reward_date <= period_to.isoformat(),
|
||||
)
|
||||
period_regular_task_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
@@ -413,6 +456,98 @@ def dashboard_overview(
|
||||
else None
|
||||
)
|
||||
|
||||
# ===== 领券核心数据(2026-07-05 产品新增)=====
|
||||
# 数据源:coupon_session(一次领券一行,started_date 北京自然日)+ coupon_claim_record
|
||||
# (一券/点位一天一条终态,claim_date 北京自然日)。点位与 session 不按 trace_id 关联——
|
||||
# record_claims 更新路径不覆盖 trace_id(同设备同券同日重跑归第一次的 trace),按
|
||||
# (device_id, 自然日) 桶关联才可靠;同桶多次发起共享同一份点位终态。
|
||||
period_coupon_sessions = db.execute(
|
||||
select(
|
||||
CouponSession.device_id,
|
||||
CouponSession.started_date,
|
||||
CouponSession.status,
|
||||
CouponSession.elapsed_ms,
|
||||
).where(
|
||||
CouponSession.started_date >= period_from,
|
||||
CouponSession.started_date <= period_to,
|
||||
# 只统计正式环境,同「领券数据」页默认口径(防 debug 包调试数据串台;
|
||||
# 命中 ix_coupon_session_date_env)。点位表无 app_env 列,但点位指标只经
|
||||
# 下方 prod session 触达的 (device, 日) 桶进入统计,随之收敛到 prod。
|
||||
CouponSession.app_env == "prod",
|
||||
)
|
||||
).all()
|
||||
coupon_started = len(period_coupon_sessions)
|
||||
coupon_completed_elapsed = sorted(
|
||||
s.elapsed_ms
|
||||
for s in period_coupon_sessions
|
||||
if s.status == "completed" and s.elapsed_ms is not None
|
||||
)
|
||||
# 点位桶:(device, 日) → (点位总数, 成功点位数)。成功口径与「我的」页累计领券一致
|
||||
# (sum_claimed_count,2026-06-15 产品定):success + already_claimed(已领过=持有券)都算成功。
|
||||
point_buckets: dict[tuple[str, date], tuple[int, int]] = {
|
||||
(dev, d): (int(total), int(succ or 0))
|
||||
for dev, d, total, succ in db.execute(
|
||||
select(
|
||||
CouponClaimRecord.device_id,
|
||||
CouponClaimRecord.claim_date,
|
||||
func.count(),
|
||||
func.sum(
|
||||
case(
|
||||
(CouponClaimRecord.status.in_(("success", "already_claimed")), 1),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
)
|
||||
.where(
|
||||
CouponClaimRecord.claim_date >= period_from,
|
||||
# 上界放宽一天:跨零点场次(23:5x 发起)的点位 claim_date 落在发起日+1,
|
||||
# 桶只经下方 session 触达的键参与计数,放宽不会引入无关数据。
|
||||
CouponClaimRecord.claim_date <= period_to + timedelta(days=1),
|
||||
)
|
||||
.group_by(CouponClaimRecord.device_id, CouponClaimRecord.claim_date)
|
||||
).all()
|
||||
}
|
||||
# 全部领成功的次数:completed 且其 (device, 日) 桶内点位全部成功(桶为空不算)。
|
||||
coupon_all_success = 0
|
||||
completed_bucket_totals: list[int] = []
|
||||
session_bucket_keys: set[tuple[str, date]] = set()
|
||||
for s in period_coupon_sessions:
|
||||
key = (s.device_id, s.started_date)
|
||||
if key not in point_buckets:
|
||||
# 跨零点回退:发起日桶不存在(点位终态全部落在次日)时取 (device, 发起日+1)。
|
||||
# 仅在发起日桶完全缺失时回退,避免抢占该设备次日 session 自己的桶。
|
||||
next_key = (s.device_id, s.started_date + timedelta(days=1))
|
||||
if next_key in point_buckets:
|
||||
key = next_key
|
||||
bucket = point_buckets.get(key)
|
||||
if bucket is not None:
|
||||
session_bucket_keys.add(key)
|
||||
if s.status != "completed" or bucket is None:
|
||||
continue
|
||||
total, succ = bucket
|
||||
completed_bucket_totals.append(total)
|
||||
if total > 0 and succ == total:
|
||||
coupon_all_success += 1
|
||||
# 每次发起的应领点位数:取「完成过的领券」实际点位数的众数(done 帧会给所有点位终态,
|
||||
# 完成场的点位数=当前配置的全量点位数;数据自校准,配置改点位数无需改代码)。本期无完成场
|
||||
# 时给不出,点位成功率置空。
|
||||
coupon_points_per_session = (
|
||||
Counter(completed_bucket_totals).most_common(1)[0][0]
|
||||
if completed_bucket_totals
|
||||
else None
|
||||
)
|
||||
# 成功点位数:本期 session 触达过的 (device, 日) 桶内成功点位之和(桶级去重,同桶重试不重复计)。
|
||||
coupon_point_success = sum(point_buckets[k][1] for k in session_bucket_keys)
|
||||
# 点位成功率 = 成功点位数 / (发起数 × 应领点位数):中途退出未跑到的点位不产生记录,
|
||||
# 但发起数×点位数把它们计入分母 → 视为失败,符合产品口径;重试会拉低该率(分母按次数计)。
|
||||
coupon_point_success_rate = (
|
||||
round(
|
||||
min(1.0, coupon_point_success / (coupon_started * coupon_points_per_session)), 4
|
||||
)
|
||||
if coupon_started and coupon_points_per_session
|
||||
else None
|
||||
)
|
||||
|
||||
return {
|
||||
"users": {
|
||||
"total": _count(User),
|
||||
@@ -428,7 +563,7 @@ def dashboard_overview(
|
||||
"reward_video_coin_total": _sum(
|
||||
CoinTransaction.amount,
|
||||
CoinTransaction.amount > 0,
|
||||
CoinTransaction.biz_type.in_(("reward_video", "ad_reward")),
|
||||
CoinTransaction.biz_type.in_(REWARD_VIDEO_BIZ_TYPES),
|
||||
),
|
||||
"reward_video_watch_count": _count(
|
||||
AdRewardRecord,
|
||||
@@ -476,11 +611,13 @@ def dashboard_overview(
|
||||
"users": {
|
||||
"new": len(period_new_user_ids),
|
||||
"active": len(period_active_user_ids),
|
||||
"retained_new_users": len(period_retained_new_user_ids),
|
||||
"retained_new_users": retention_retained_total,
|
||||
"retention_cohort": retention_cohort_total,
|
||||
"retention_rate": period_retention_rate,
|
||||
"retention_note": (
|
||||
"口径:登录(last_login_at)+开始比价(real_compare_start)+"
|
||||
"开始领券(real_coupon_start/claim_started),按用户去重"
|
||||
"次日留存:窗口内每天取前一日新增用户,统计其当日活跃"
|
||||
"(登录/开始比价/开始领券,按用户去重)比例,逐日累加;"
|
||||
"默认窗口=昨日,即前日新增用户的昨日留存"
|
||||
),
|
||||
},
|
||||
"comparison": {
|
||||
@@ -491,6 +628,17 @@ def dashboard_overview(
|
||||
"average_duration_ms": period_avg_duration_ms,
|
||||
"average_saved_cents": period_avg_saved_cents,
|
||||
},
|
||||
"coupon": {
|
||||
"started": coupon_started,
|
||||
"all_success": coupon_all_success,
|
||||
"success_rate": (
|
||||
round(coupon_all_success / coupon_started, 4) if coupon_started else None
|
||||
),
|
||||
"point_success": coupon_point_success,
|
||||
"points_per_session": coupon_points_per_session,
|
||||
"point_success_rate": coupon_point_success_rate,
|
||||
"median_elapsed_ms": _percentile(coupon_completed_elapsed, 50),
|
||||
},
|
||||
"coins": {
|
||||
"granted_total": _sum(CoinTransaction.amount, *period_coin_conds),
|
||||
"reward_video_coin_total": period_reward_video_coin_total,
|
||||
|
||||
@@ -91,6 +91,7 @@ def get_ad_revenue_report(
|
||||
daily=[AdRevenueDaily(**d) for d in result["daily"]],
|
||||
hourly=[AdRevenueHourly(**h) for h in result["hourly"]],
|
||||
type_stats={k: AdRevenueTypeStat(**v) for k, v in result["type_stats"].items()},
|
||||
scene_stats={k: AdRevenueTypeStat(**v) for k, v in result["scene_stats"].items()},
|
||||
dau=result["dau"],
|
||||
total=result["total"],
|
||||
truncated=result["truncated"],
|
||||
|
||||
@@ -5,6 +5,8 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from app.admin.audit import write_audit
|
||||
from app.admin.deps import AdminDb, CurrentAdmin, get_client_ip, require_role
|
||||
from app.admin.permissions import CUSTOM_ROLE, SUPER_ADMIN_ROLE, sanitize_pages
|
||||
from app.admin.repositories import admin_role as role_repo
|
||||
from app.admin.repositories import admin_user as admin_repo
|
||||
from app.admin.schemas.admin import AdminCreateRequest, AdminUpdateRequest
|
||||
from app.admin.schemas.auth import AdminOut
|
||||
@@ -23,9 +25,31 @@ def _active_super_count(db: AdminDb) -> int:
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[AdminOut], summary="管理员列表")
|
||||
def _validate_role(db: AdminDb, role: str) -> None:
|
||||
"""角色必须是 super_admin / custom(自定义) / admin_role 表里已存在的角色,否则 400。"""
|
||||
if role in (SUPER_ADMIN_ROLE, CUSTOM_ROLE):
|
||||
return
|
||||
role_repo.ensure_builtin_roles(db) # 空表(测试/全新库)兜底播种,再校验
|
||||
if role_repo.get_role(db, role) is None:
|
||||
raise HTTPException(status_code=400, detail=f"角色不存在: {role}")
|
||||
|
||||
|
||||
def _clean_override(role: str, pages_override: list[str] | None) -> list[str] | None:
|
||||
"""按最终角色算出该存的 pages_override:custom → 勾选集(过滤非法 key,可空列表);
|
||||
非 custom → None(切回普通角色即清空自定义页)。"""
|
||||
if role == CUSTOM_ROLE:
|
||||
return sanitize_pages(pages_override)
|
||||
return None
|
||||
|
||||
|
||||
@router.get("", response_model=list[AdminOut], summary="管理员列表(含明文密码,super_admin 专属)")
|
||||
def list_admins(db: AdminDb) -> list[AdminOut]:
|
||||
return [AdminOut.model_validate(a) for a in admin_repo.list_admins(db)]
|
||||
out: list[AdminOut] = []
|
||||
for a in admin_repo.list_admins(db):
|
||||
item = AdminOut.model_validate(a)
|
||||
item.password = a.plain_password # 明文:仅本 super_admin 专属路由下发,供权限管理页复看
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
@router.post("", response_model=AdminOut, summary="创建管理员")
|
||||
@@ -34,12 +58,17 @@ def create_admin(
|
||||
) -> AdminOut:
|
||||
if admin_repo.get_by_username(db, body.username) is not None:
|
||||
raise HTTPException(status_code=409, detail="用户名已存在")
|
||||
_validate_role(db, body.role)
|
||||
override = _clean_override(body.role, body.pages_override)
|
||||
new = admin_repo.create_admin(
|
||||
db, username=body.username, password=body.password, role=body.role
|
||||
db, username=body.username, password=body.password, role=body.role,
|
||||
plain_password=body.password, # UI 建的账号留存明文,供权限管理页复看
|
||||
pages_override=override,
|
||||
)
|
||||
write_audit(
|
||||
db, admin, action="admin.create", target_type="admin", target_id=new.id,
|
||||
detail={"username": new.username, "role": new.role}, ip=get_client_ip(request), commit=True,
|
||||
detail={"username": new.username, "role": new.role, "pages_override": override},
|
||||
ip=get_client_ip(request), commit=True,
|
||||
)
|
||||
return AdminOut.model_validate(new)
|
||||
|
||||
@@ -67,8 +96,12 @@ def update_admin(
|
||||
if demotes_super and _active_super_count(db) <= 1:
|
||||
raise HTTPException(status_code=400, detail="不能降级/禁用最后一个超级管理员")
|
||||
|
||||
if body.role is not None:
|
||||
_validate_role(db, body.role)
|
||||
|
||||
changes: dict = {}
|
||||
if body.role is not None and body.role != target.role:
|
||||
role_changed = body.role is not None and body.role != target.role
|
||||
if role_changed:
|
||||
changes["role"] = {"before": target.role, "after": body.role}
|
||||
target.role = body.role
|
||||
if body.status is not None and body.status != target.status:
|
||||
@@ -77,6 +110,18 @@ def update_admin(
|
||||
if body.password is not None:
|
||||
changes["password"] = "reset"
|
||||
target.password_hash = hash_password(body.password)
|
||||
target.plain_password = body.password # 同步留存明文,权限管理页复看保持一致
|
||||
|
||||
# 自定义可见页:按「最终角色」(target.role,已应用完角色变更)决定该存什么。
|
||||
# - 切到/维持 custom 且传了 pages_override → 用勾选集;切到 custom 没传 → 清成空列表。
|
||||
# - 切回普通角色 → 清空 override(_clean_override 返回 None)。
|
||||
# - 角色没变、只传 pages_override(编辑现有 custom 用户的勾选)→ 也更新。
|
||||
if role_changed or body.pages_override is not None:
|
||||
new_override = _clean_override(target.role, body.pages_override)
|
||||
if new_override != target.pages_override:
|
||||
changes["pages_override"] = {"before": target.pages_override, "after": new_override}
|
||||
target.pages_override = new_override
|
||||
|
||||
if not changes:
|
||||
raise HTTPException(status_code=400, detail="无任何变更字段")
|
||||
db.commit()
|
||||
@@ -86,3 +131,27 @@ def update_admin(
|
||||
detail=changes, ip=get_client_ip(request), commit=True,
|
||||
)
|
||||
return AdminOut.model_validate(target)
|
||||
|
||||
|
||||
@router.delete("/{admin_id}", summary="删除管理员(带审计)")
|
||||
def delete_admin(admin_id: int, request: Request, admin: CurrentAdmin, db: AdminDb) -> dict:
|
||||
target = admin_repo.get_by_id(db, admin_id)
|
||||
if target is None:
|
||||
raise HTTPException(status_code=404, detail="管理员不存在")
|
||||
if admin_id == admin.id:
|
||||
raise HTTPException(status_code=400, detail="不能删除自己")
|
||||
# 防自锁:删掉某个 active super_admin 前,确认后仍至少剩 1 个,否则进「零可用超管」死局。
|
||||
if (
|
||||
target.role == "super_admin"
|
||||
and target.status == "active"
|
||||
and _active_super_count(db) <= 1
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="不能删除最后一个超级管理员")
|
||||
username = target.username
|
||||
db.delete(target)
|
||||
db.commit()
|
||||
write_audit(
|
||||
db, admin, action="admin.delete", target_type="admin", target_id=admin_id,
|
||||
detail={"username": username, "role": target.role}, ip=get_client_ip(request), commit=True,
|
||||
)
|
||||
return {"deleted": True}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""admin 埋点健康度:埋点成功率 / 上报成功率 总览 + 趋势 + 下钻(只读)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.admin.deps import AdminDb, get_current_admin
|
||||
from app.admin.repositories import analytics_health as repo
|
||||
from app.admin.schemas.analytics_health import (
|
||||
HealthBreakdownRow,
|
||||
HealthMetrics,
|
||||
HealthTrendPoint,
|
||||
)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/analytics-health",
|
||||
tags=["admin-analytics-health"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/overview", response_model=HealthMetrics, summary="两段成功率总览")
|
||||
def overview(
|
||||
db: AdminDb,
|
||||
date_from: Annotated[datetime, Query()],
|
||||
date_to: Annotated[datetime, Query()],
|
||||
) -> HealthMetrics:
|
||||
return HealthMetrics(**repo.overview(db, date_from, date_to))
|
||||
|
||||
|
||||
@router.get("/trend", response_model=list[HealthTrendPoint], summary="按北京天趋势")
|
||||
def trend(
|
||||
db: AdminDb,
|
||||
date_from: Annotated[datetime, Query()],
|
||||
date_to: Annotated[datetime, Query()],
|
||||
) -> list[HealthTrendPoint]:
|
||||
return [HealthTrendPoint(**p) for p in repo.trend(db, date_from, date_to)]
|
||||
|
||||
|
||||
@router.get("/breakdown", response_model=list[HealthBreakdownRow], summary="按维度下钻")
|
||||
def breakdown(
|
||||
db: AdminDb,
|
||||
date_from: Annotated[datetime, Query()],
|
||||
date_to: Annotated[datetime, Query()],
|
||||
dim: Annotated[str, Query(pattern="^(event|app_ver|oem)$")] = "event",
|
||||
) -> list[HealthBreakdownRow]:
|
||||
return [HealthBreakdownRow(**r) for r in repo.breakdown(db, date_from, date_to, dim)]
|
||||
@@ -6,6 +6,8 @@ import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.admin.deps import AdminDb, CurrentAdmin
|
||||
from app.admin.permissions import CUSTOM_ROLE, sanitize_pages
|
||||
from app.admin.repositories import admin_role as role_repo
|
||||
from app.admin.repositories import admin_user as admin_repo
|
||||
from app.admin.schemas.auth import AdminLoginRequest, AdminLoginResponse, AdminOut
|
||||
from app.admin.security import create_admin_token
|
||||
@@ -17,6 +19,17 @@ logger = logging.getLogger("shagua.admin.auth")
|
||||
router = APIRouter(prefix="/admin/api/auth", tags=["admin-auth"])
|
||||
|
||||
|
||||
def _admin_out_with_pages(admin, db: AdminDb) -> AdminOut: # noqa: ANN001
|
||||
"""AdminOut + 有效可见页(前端左侧导航按此过滤)。
|
||||
role=="custom" → 用这个人的 pages_override(按人自定义,过滤悬空 key);其余走角色解析。"""
|
||||
out = AdminOut.model_validate(admin)
|
||||
if admin.role == CUSTOM_ROLE:
|
||||
out.pages = sanitize_pages(admin.pages_override)
|
||||
else:
|
||||
out.pages = role_repo.effective_pages_of(db, admin.role)
|
||||
return out
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login",
|
||||
response_model=AdminLoginResponse,
|
||||
@@ -39,10 +52,10 @@ def login(req: AdminLoginRequest, db: AdminDb) -> AdminLoginResponse:
|
||||
return AdminLoginResponse(
|
||||
access_token=token,
|
||||
expires_in=expires_in,
|
||||
admin=AdminOut.model_validate(admin),
|
||||
admin=_admin_out_with_pages(admin, db),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=AdminOut, summary="当前管理员")
|
||||
def me(admin: CurrentAdmin) -> AdminOut:
|
||||
return AdminOut.model_validate(admin)
|
||||
@router.get("/me", response_model=AdminOut, summary="当前管理员(含有效可见页)")
|
||||
def me(admin: CurrentAdmin, db: AdminDb) -> AdminOut:
|
||||
return _admin_out_with_pages(admin, db)
|
||||
|
||||
@@ -32,12 +32,15 @@ def list_comparison_records(
|
||||
phone: Annotated[str | None, Query(description="手机号前缀")] = None,
|
||||
status: Annotated[str | None, Query(pattern="^(success|failed|cancelled)$")] = None,
|
||||
business_type: Annotated[str | None, Query()] = None,
|
||||
store: Annotated[str | None, Query(description="店名子串模糊匹配")] = None,
|
||||
product: Annotated[str | None, Query(description="商品名子串模糊匹配")] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[AdminComparisonListItem]:
|
||||
items, next_cursor, total = queries.list_comparison_records(
|
||||
db, user_id=user_id, phone=phone, status=status,
|
||||
business_type=business_type, limit=limit, cursor=cursor,
|
||||
business_type=business_type, store=store, product=product,
|
||||
limit=limit, cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
items=[AdminComparisonListItem.model_validate(r) for r in items],
|
||||
|
||||
@@ -55,9 +55,14 @@ def _item(db, key: str) -> ConfigItemOut:
|
||||
raise HTTPException(status_code=404, detail="未知配置项")
|
||||
|
||||
|
||||
@router.get("", response_model=list[ConfigItemOut], summary="所有可配项 + 当前值")
|
||||
@router.get("", response_model=list[ConfigItemOut], summary="所有可配项 + 当前值(不含 hidden)")
|
||||
def list_config(db: AdminDb) -> list[ConfigItemOut]:
|
||||
return [ConfigItemOut(**item) for item in app_config.list_all(db)]
|
||||
# hidden 项(已下线/由专用页管理,如福利页任务·里程碑·看广告调参、首页轮播数据源)不在本页渲染。
|
||||
return [
|
||||
ConfigItemOut(**item)
|
||||
for item in app_config.list_all(db)
|
||||
if not CONFIG_DEFS[item["key"]].get("hidden")
|
||||
]
|
||||
|
||||
|
||||
@router.patch("/{key}", response_model=ConfigItemOut, summary="改某项配置(带审计)")
|
||||
|
||||
@@ -18,6 +18,8 @@ from app.admin.schemas.coupon_data import (
|
||||
CouponDataOut,
|
||||
CouponDataRow,
|
||||
CouponDataSummary,
|
||||
CouponSlotRow,
|
||||
CouponSlotsOut,
|
||||
CouponUserRecordsOut,
|
||||
)
|
||||
from app.core.rewards import cn_today
|
||||
@@ -52,6 +54,10 @@ def get_coupon_data(
|
||||
date_to: Annotated[str | None, Query(description="结束日 北京 YYYY-MM-DD,闭区间,默认=date_from")] = None,
|
||||
user: Annotated[str | None, Query(description="用户手机号/昵称模糊搜;不传=全部")] = None,
|
||||
app_env: Annotated[str, Query(description="prod(默认) / dev / all(全部环境)")] = "prod",
|
||||
status: Annotated[
|
||||
list[str] | None,
|
||||
Query(description="领券状态多选 started/completed/failed/abandoned;不传=全部"),
|
||||
] = None,
|
||||
granularity: Annotated[
|
||||
str, Query(description="day=按天 / hour=按小时(北京);区间>1 天建议 day")
|
||||
] = "day",
|
||||
@@ -73,7 +79,7 @@ def get_coupon_data(
|
||||
env = None if app_env == "all" else app_env
|
||||
result = coupon_data.coupon_data_report(
|
||||
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(),
|
||||
user=user, app_env=env, granularity=granularity,
|
||||
user=user, app_env=env, statuses=status, granularity=granularity,
|
||||
limit=limit, offset=offset, sort=sort,
|
||||
)
|
||||
return CouponDataOut(
|
||||
@@ -87,6 +93,35 @@ def get_coupon_data(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/coupons",
|
||||
response_model=CouponSlotsOut,
|
||||
summary="按券成功率(coupon_id 粒度;成功/(成功+失败),skipped 排除,设备-天口径)",
|
||||
)
|
||||
def get_coupon_slots(
|
||||
db: AdminDb,
|
||||
date_from: Annotated[str | None, Query(description="起始日 北京 YYYY-MM-DD,默认今天")] = None,
|
||||
date_to: Annotated[str | None, Query(description="结束日 北京 YYYY-MM-DD,闭区间,默认=date_from")] = None,
|
||||
app_env: Annotated[str, Query(description="prod(默认) / dev / all(全部环境)")] = "prod",
|
||||
) -> CouponSlotsOut:
|
||||
today = cn_today()
|
||||
d_from = _parse_day(date_from, field="date_from", default=today)
|
||||
d_to = _parse_day(date_to, field="date_to", default=d_from)
|
||||
if d_to < d_from:
|
||||
raise HTTPException(status_code=422, detail="date_to 不能早于 date_from")
|
||||
if (d_to - d_from).days + 1 > _MAX_RANGE_DAYS:
|
||||
raise HTTPException(status_code=422, detail=f"区间最长 {_MAX_RANGE_DAYS} 天")
|
||||
env = None if app_env == "all" else app_env
|
||||
result = coupon_data.coupon_slot_report(
|
||||
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(), app_env=env
|
||||
)
|
||||
return CouponSlotsOut(
|
||||
date_from=d_from.isoformat(),
|
||||
date_to=d_to.isoformat(),
|
||||
items=[CouponSlotRow(**r) for r in result["items"]],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/user-records",
|
||||
response_model=CouponUserRecordsOut,
|
||||
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.admin.audit import write_audit
|
||||
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
||||
@@ -18,11 +19,17 @@ from app.admin.schemas.ops_marquee_seed import (
|
||||
OpsMarqueeSeedCreate,
|
||||
OpsMarqueeSeedOut,
|
||||
OpsMarqueeSeedUpdate,
|
||||
OpsRealRecordItem,
|
||||
OpsRealRecordsOut,
|
||||
OpsSavingsFeedPreviewOut,
|
||||
)
|
||||
from app.models.admin import AdminUser
|
||||
from app.models.ops_marquee_seed import OpsMarqueeSeed
|
||||
from app.repositories import ops_marquee
|
||||
from app.repositories import app_config, ops_marquee
|
||||
|
||||
|
||||
class MarqueeModeUpdate(BaseModel):
|
||||
mode: str # mixed / real / seed
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/marquee-seeds",
|
||||
@@ -52,9 +59,58 @@ def list_seeds(db: AdminDb) -> list[OpsMarqueeSeedOut]:
|
||||
def preview_feed(
|
||||
db: AdminDb,
|
||||
limit: Annotated[int, Query(ge=1, le=30)] = 8,
|
||||
mode: Annotated[str | None, Query(description="mixed/real/seed;不传=当前持久化模式")] = None,
|
||||
) -> OpsSavingsFeedPreviewOut:
|
||||
"""返回客户端实际会看到的 feed(真实记录会插队、种子随机抽取/金额随机/名字合成),供运营对效果。"""
|
||||
return OpsSavingsFeedPreviewOut(items=ops_marquee.get_feed(db, limit=limit))
|
||||
"""返回客户端实际会看到的 feed(真实记录会插队、种子随机抽取/金额随机/名字合成),供运营对效果。
|
||||
|
||||
mode 显式指定则预览该模式(**不改持久化配置**,供前端切换开关时实时预览);不传则用当前持久化模式。
|
||||
"""
|
||||
if mode is not None and mode not in ops_marquee.FEED_MODES:
|
||||
raise HTTPException(status_code=400, detail="mode 需为 mixed / real / seed")
|
||||
return OpsSavingsFeedPreviewOut(items=ops_marquee.get_feed(db, limit=limit, mode=mode))
|
||||
|
||||
|
||||
@router.get("/real-records", response_model=OpsRealRecordsOut, summary="分页浏览当前模式下可展示的记录(审核用)")
|
||||
def list_real_records(
|
||||
db: AdminDb,
|
||||
mode: Annotated[str | None, Query(description="mixed/real/seed;不传=当前持久化模式")] = None,
|
||||
offset: Annotated[int, Query(ge=0)] = 0,
|
||||
limit: Annotated[int, Query(ge=1, le=50)] = 8,
|
||||
) -> OpsRealRecordsOut:
|
||||
"""分页列出**当前模式**下可在 app 轮播展示的全部记录(**不去重**):只真实=真实记录;只种子=各启用
|
||||
种子按生成逻辑各出一行;混播=真实+种子。与 app 同口径洗牌+去连簇,固定种子→翻页稳定、能翻遍全部。
|
||||
item.user_id=0 表示种子行。"""
|
||||
if mode is not None and mode not in ops_marquee.FEED_MODES:
|
||||
raise HTTPException(status_code=400, detail="mode 需为 mixed / real / seed")
|
||||
items, total = ops_marquee.list_real_records(db, mode=mode, offset=offset, limit=limit)
|
||||
return OpsRealRecordsOut(items=[OpsRealRecordItem(**it) for it in items], total=total)
|
||||
|
||||
|
||||
# 注:/mode 两个端点须在 /{seed_id} 之前注册,否则 PATCH /mode 会被 /{seed_id} 抢先按 id 解析。
|
||||
@router.get("/mode", summary="首页轮播数据源模式(mixed/real/seed)")
|
||||
def get_feed_mode(db: AdminDb) -> dict:
|
||||
"""当前轮播取数模式:mixed=真实优先+种子补位(默认)/ real=只真实 / seed=只种子·合成。"""
|
||||
return {"mode": ops_marquee.get_feed_mode(db)}
|
||||
|
||||
|
||||
@router.patch("/mode", summary="改首页轮播数据源模式(带审计)")
|
||||
def set_feed_mode(
|
||||
body: MarqueeModeUpdate,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> dict:
|
||||
if body.mode not in ops_marquee.FEED_MODES:
|
||||
raise HTTPException(status_code=400, detail="mode 需为 mixed / real / seed")
|
||||
before = ops_marquee.get_feed_mode(db)
|
||||
app_config.set_value(db, "marquee_feed_mode", body.mode, admin_id=admin.id, commit=False)
|
||||
write_audit(
|
||||
db, admin, action="ops_marquee_seed.set_mode", target_type="config",
|
||||
target_id="marquee_feed_mode", detail={"before": before, "after": body.mode},
|
||||
ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return {"mode": body.mode}
|
||||
|
||||
|
||||
@router.post("", response_model=OpsMarqueeSeedOut, summary="新增轮播种子(带审计)")
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""admin RBAC 角色管理(仅 super_admin):列角色 / 权限目录 / 增删改角色。均写审计。
|
||||
|
||||
角色 = 一组「可见页面」(见 app/admin/permissions.py)。角色登录后台后左侧只展示其 pages 对应导航项。
|
||||
只有 super_admin(内建全权角色)能进本组端点(dependencies=require_role() 无参 = 仅 super_admin)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.admin.audit import write_audit
|
||||
from app.admin.deps import AdminDb, CurrentAdmin, get_client_ip, require_role
|
||||
from app.admin.permissions import (
|
||||
PERMISSION_CATALOG,
|
||||
SUPER_ADMIN_ROLE,
|
||||
effective_pages,
|
||||
sanitize_pages,
|
||||
)
|
||||
from app.admin.repositories import admin_role as role_repo
|
||||
from app.admin.schemas.role import (
|
||||
PermissionGroup,
|
||||
RoleCreateRequest,
|
||||
RoleOut,
|
||||
RoleUpdateRequest,
|
||||
)
|
||||
from app.models.admin import AdminUser
|
||||
from app.models.admin_role import AdminRole
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/roles",
|
||||
tags=["admin-roles"],
|
||||
dependencies=[Depends(require_role())], # 无参 = 仅 super_admin
|
||||
)
|
||||
|
||||
|
||||
def _usage_counts(db: AdminDb) -> dict[str, int]:
|
||||
rows = db.execute(
|
||||
select(AdminUser.role, func.count(AdminUser.id)).group_by(AdminUser.role)
|
||||
).all()
|
||||
return {r: c for r, c in rows}
|
||||
|
||||
|
||||
def _to_out(role: AdminRole, usage: dict[str, int]) -> RoleOut:
|
||||
return RoleOut(
|
||||
id=role.id,
|
||||
name=role.name,
|
||||
label=role.label or role.name,
|
||||
pages=effective_pages(role.name, role.pages),
|
||||
is_builtin=role.is_builtin,
|
||||
in_use=usage.get(role.name, 0),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[RoleOut], summary="角色列表(含可见页 + 使用数)")
|
||||
def list_roles(db: AdminDb) -> list[RoleOut]:
|
||||
usage = _usage_counts(db)
|
||||
return [_to_out(r, usage) for r in role_repo.list_roles(db)]
|
||||
|
||||
|
||||
@router.get("/catalog", response_model=list[PermissionGroup], summary="页面权限目录(分组)")
|
||||
def get_catalog() -> list[PermissionGroup]:
|
||||
return [PermissionGroup(**g) for g in PERMISSION_CATALOG]
|
||||
|
||||
|
||||
@router.post("", response_model=RoleOut, summary="新增角色(带审计)")
|
||||
def create_role(
|
||||
body: RoleCreateRequest, request: Request, admin: CurrentAdmin, db: AdminDb
|
||||
) -> RoleOut:
|
||||
name = body.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="角色名称不能为空")
|
||||
if name == SUPER_ADMIN_ROLE:
|
||||
raise HTTPException(status_code=400, detail="super_admin 为内建角色,不能新建")
|
||||
if role_repo.get_role(db, name) is not None:
|
||||
raise HTTPException(status_code=409, detail="角色名称已存在")
|
||||
# 自定义角色:name(key)= label = 输入名称
|
||||
role = role_repo.create_role(db, name=name, label=name, pages=body.pages)
|
||||
write_audit(
|
||||
db, admin, action="role.create", target_type="role", target_id=str(role.id),
|
||||
detail={"name": name, "pages": role.pages}, ip=get_client_ip(request), commit=True,
|
||||
)
|
||||
return _to_out(role, _usage_counts(db))
|
||||
|
||||
|
||||
@router.patch("/{role_id}", response_model=RoleOut, summary="改角色(展示名/可见页,带审计)")
|
||||
def update_role(
|
||||
role_id: int, body: RoleUpdateRequest, request: Request, admin: CurrentAdmin, db: AdminDb
|
||||
) -> RoleOut:
|
||||
role = db.get(AdminRole, role_id)
|
||||
if role is None:
|
||||
raise HTTPException(status_code=404, detail="角色不存在")
|
||||
if role.is_builtin:
|
||||
raise HTTPException(status_code=400, detail="内建角色「管理员」不可编辑")
|
||||
|
||||
new_label = body.label.strip() if body.label is not None else None
|
||||
changes: dict = {}
|
||||
if new_label and new_label != role.label:
|
||||
changes["label"] = {"before": role.label, "after": new_label}
|
||||
if body.pages is not None:
|
||||
changes["pages"] = {"before": role.pages, "after": sanitize_pages(body.pages)}
|
||||
if not changes:
|
||||
raise HTTPException(status_code=400, detail="无任何变更字段")
|
||||
|
||||
# 只改展示名 + 可见页;key(name)不可变,故无需级联 admin_user.role
|
||||
role_repo.update_role(db, role, label=new_label, pages=body.pages)
|
||||
write_audit(
|
||||
db, admin, action="role.update", target_type="role", target_id=str(role_id),
|
||||
detail=changes, ip=get_client_ip(request), commit=True,
|
||||
)
|
||||
return _to_out(role, _usage_counts(db))
|
||||
|
||||
|
||||
@router.delete("/{role_id}", summary="删角色(带审计;内建/在用不可删)")
|
||||
def delete_role(role_id: int, request: Request, admin: CurrentAdmin, db: AdminDb) -> dict:
|
||||
role = db.get(AdminRole, role_id)
|
||||
if role is None:
|
||||
raise HTTPException(status_code=404, detail="角色不存在")
|
||||
if role.is_builtin:
|
||||
raise HTTPException(status_code=400, detail="内建角色不可删除")
|
||||
used = _usage_counts(db).get(role.name, 0)
|
||||
if used > 0:
|
||||
raise HTTPException(status_code=400, detail=f"该角色仍有 {used} 名管理员在用,请先改派再删")
|
||||
name = role.name
|
||||
role_repo.delete_role(db, role)
|
||||
write_audit(
|
||||
db, admin, action="role.delete", target_type="role", target_id=str(role_id),
|
||||
detail={"name": name}, ip=get_client_ip(request), commit=True,
|
||||
)
|
||||
return {"deleted": True}
|
||||
@@ -42,7 +42,12 @@ def list_users(
|
||||
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",
|
||||
# 最近活跃(登录/发起比价/发起领券取最大,见 queries._last_active_expr)筛选与排序
|
||||
last_active_from: Annotated[datetime | None, Query()] = None,
|
||||
last_active_to: Annotated[datetime | None, Query()] = None,
|
||||
sort_by: Annotated[
|
||||
str, Query(pattern="^(id|created_at|last_login_at|last_active_at)$")
|
||||
] = "id",
|
||||
sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc",
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
@@ -51,6 +56,7 @@ def list_users(
|
||||
db, phone=phone, register_channel=register_channel, status=status,
|
||||
nickname=nickname, created_from=created_from, created_to=created_to,
|
||||
last_login_from=last_login_from, last_login_to=last_login_to,
|
||||
last_active_from=last_active_from, last_active_to=last_active_to,
|
||||
sort_by=sort_by, sort_order=sort_order, limit=limit, cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
|
||||
@@ -136,9 +136,16 @@ class AdRevenueReportOut(BaseModel):
|
||||
default_factory=dict,
|
||||
description="按广告类型(ad_type)小计 {ad_type: {impressions, revenue_yuan}};前端取 draw / reward_video 做分类大盘",
|
||||
)
|
||||
scene_stats: dict[str, AdRevenueTypeStat] = Field(
|
||||
default_factory=dict,
|
||||
description="按信息流场景(feed_scene)小计 {comparison/coupon/welfare: {impressions, revenue_yuan}};"
|
||||
"全量统计(不受分页截断),供数据大盘「领券广告 / 比价广告」卡;feed_scene 为空的事件不计入",
|
||||
)
|
||||
dau: int | None = Field(
|
||||
None,
|
||||
description="今日活跃用户数(复用大盘口径,last_login_at);**仅查询=今日单天时有值**,历史/多天为 null",
|
||||
description="所选日期区间的去重活跃用户数(口径同数据大盘 period.users.active:登录 + 开始比价 + "
|
||||
"开始领券)。按 date_from~date_to 区间统计,含今日、近 7 天、近 30 天等任意区间;全局口径,"
|
||||
"不随 user_id / ad_type / feed_scene / app_env 筛选变化",
|
||||
)
|
||||
total: int = Field(..., description="广告事件总数(全量,不受分页影响;= 当前筛选下的分页总条数)")
|
||||
truncated: bool = Field(..., description="当前页之后是否还有更多事件(len(events) > offset + limit)")
|
||||
|
||||
@@ -6,21 +6,25 @@ from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
_Role = Literal["super_admin", "finance", "operator"]
|
||||
# 角色不再硬编码枚举:改为任意角色名(自定义角色由 admin_role 表管理),存在性在路由层校验。
|
||||
|
||||
|
||||
class AdminCreateRequest(BaseModel):
|
||||
username: str = Field(..., min_length=3, max_length=64)
|
||||
password: str = Field(..., min_length=8, max_length=72) # bcrypt ≤72 字节
|
||||
role: _Role = "operator"
|
||||
role: str = Field("operator", min_length=1, max_length=32)
|
||||
# 仅当 role == "custom":这个人专属可见页 key 列表(逐页勾选结果)。其余角色不传/忽略。
|
||||
pages_override: list[str] | None = None
|
||||
|
||||
|
||||
class AdminUpdateRequest(BaseModel):
|
||||
"""改角色 / 启用禁用 / 重置密码,字段都可选(只改传了的)。"""
|
||||
"""改角色 / 启用禁用 / 重置密码 / 自定义可见页,字段都可选(只改传了的)。"""
|
||||
|
||||
role: _Role | None = None
|
||||
role: str | None = Field(None, min_length=1, max_length=32)
|
||||
status: Literal["active", "disabled"] | None = None
|
||||
password: str | None = Field(None, min_length=8, max_length=72)
|
||||
# 改成/更新「自定义」可见页;role 切回普通角色时后端会清空 override(见路由)。
|
||||
pages_override: list[str] | None = None
|
||||
|
||||
|
||||
class AdminAuditLogOut(BaseModel):
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""埋点健康度 admin 响应 schema。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class HealthMetrics(BaseModel):
|
||||
attempted: int
|
||||
drop_capture: int
|
||||
delivered: int
|
||||
drop_undelivered: int
|
||||
track_success_rate: float | None
|
||||
report_success_rate: float | None
|
||||
|
||||
|
||||
class HealthTrendPoint(HealthMetrics):
|
||||
day: str
|
||||
|
||||
|
||||
class HealthBreakdownRow(HealthMetrics):
|
||||
key: str
|
||||
@@ -20,6 +20,15 @@ class AdminOut(BaseModel):
|
||||
status: str
|
||||
created_at: datetime
|
||||
last_login_at: datetime | None = None
|
||||
# 该管理员当前角色的有效可见页(= 左侧导航项 key);仅登录 / /me 填充,列表接口默认空。
|
||||
# 前端据此过滤左侧导航(super_admin = 全部页;role=="custom" = pages_override)。见 permissions.py。
|
||||
pages: list[str] = []
|
||||
# 「自定义」可见页原始勾选集(role=="custom" 时非空);列表接口下发,供权限管理页编辑回填勾选。
|
||||
# 普通角色为 None。from_attributes 自动从 ORM 列取。
|
||||
pages_override: list[str] | None = None
|
||||
# 明文登录密码:仅「管理员账号列表」(super_admin 专属路由)填充,供权限管理页编辑时复看;
|
||||
# 无留存(脚本建的超管 / 旧账号)为 None → 前端不显示。登录 / /me 不下发(保持 None)。
|
||||
password: str | None = None
|
||||
|
||||
|
||||
class AdminLoginResponse(BaseModel):
|
||||
|
||||
@@ -23,6 +23,7 @@ class AdminComparisonListItem(BaseModel):
|
||||
status: str
|
||||
information: str | None = None
|
||||
store_name: str | None = None
|
||||
product_names: str | None = None # 下单商品名派生串(顿号分隔;「商品」列展示 + 商品搜索)
|
||||
source_platform_name: str | None = None
|
||||
best_platform_name: str | None = None
|
||||
source_price_cents: int | None = None
|
||||
@@ -40,6 +41,7 @@ class AdminComparisonListItem(BaseModel):
|
||||
rom_name: str | None = None
|
||||
android_version: str | None = None
|
||||
app_version: str | None = None
|
||||
ad_revenue_yuan: float = 0.0 # 本次比价看的信息流广告预估收益(元),queries 瞬态挂 ORM 实例上
|
||||
created_at: datetime
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,16 @@ class CouponDataSummary(BaseModel):
|
||||
p50_ms: int | None = Field(None, description="耗时 50 分位(ms,中位数)")
|
||||
p95_ms: int | None = Field(None, description="耗时 95 分位(ms)")
|
||||
p99_ms: int | None = Field(None, description="耗时 99 分位(ms)")
|
||||
# 平台粒度成功率(见 docs/guides/领券成功率指标-设计与埋点.md):基数含全部 session。
|
||||
full_success_count: int = Field(0, description="整单成功数(勾选平台全部领到的 session 数)")
|
||||
full_success_rate: float | None = Field(None, description="整单成功率②=整单成功数/发起数;无数据为空")
|
||||
point_success_count: int = Field(0, description="成功平台点位数(Σ 每次成功的平台数)")
|
||||
point_total_count: int = Field(0, description="总平台点位数(Σ 每次勾选平台数;空勾选=全领三档)")
|
||||
point_success_rate: float | None = Field(None, description="点位成功率③=成功点位/总点位;无数据为空")
|
||||
per_platform: dict[str, float | None] = Field(
|
||||
default_factory=dict,
|
||||
description="分平台点位成功率 {平台id: rate|None};恒含美团/淘宝/京东三档,区间内无人勾选的平台为 None",
|
||||
)
|
||||
|
||||
|
||||
class CouponDataDaily(BaseModel):
|
||||
@@ -60,6 +70,9 @@ class CouponDataRow(BaseModel):
|
||||
started_at: datetime = Field(..., description="发起时刻(明细「时间」列)")
|
||||
claimed_count: int | None = None
|
||||
trace_url: str | None = Field(None, description="pricebot 公网 trace 链接(仅 completed 有);admin 渲染可点链接,无则显示可复制 trace_id")
|
||||
ad_revenue_yuan: float = Field(
|
||||
0.0, description="本次领券看的信息流广告预估收益(元);按 trace_id 聚合 ad_ecpm_record"
|
||||
)
|
||||
|
||||
|
||||
class CouponDataOut(BaseModel):
|
||||
@@ -81,3 +94,22 @@ class CouponUserRecordsOut(BaseModel):
|
||||
|
||||
items: list[CouponDataRow]
|
||||
total: int
|
||||
|
||||
|
||||
class CouponSlotRow(BaseModel):
|
||||
"""按券成功率一行(§13):粒度=设备-天;成功率=成功/(成功+失败),skipped 排除。"""
|
||||
|
||||
coupon_id: str
|
||||
coupon_name: str | None = None
|
||||
platform: str | None = Field(None, description="美团/淘宝/京东 平台 id;无法识别为空")
|
||||
tried: int = Field(..., description="尝试数(success+already_claimed+failed 的设备-天数)")
|
||||
succeeded: int = Field(..., description="成功数(success+already_claimed)")
|
||||
success_rate: float | None = Field(None, description="成功率=成功/尝试")
|
||||
|
||||
|
||||
class CouponSlotsOut(BaseModel):
|
||||
"""按券成功率表响应(§13)。"""
|
||||
|
||||
date_from: str
|
||||
date_to: str
|
||||
items: list[CouponSlotRow]
|
||||
|
||||
@@ -43,7 +43,10 @@ class DashboardComparison(BaseModel):
|
||||
class DashboardPeriodUsers(BaseModel):
|
||||
new: int
|
||||
active: int
|
||||
# 次日留存(2026-07-05 起):retained_new_users = 窗口内逐日「前一日新增且当日活跃」用户数之和,
|
||||
# retention_cohort = 对应的前一日新增基数之和,retention_rate = 两者之比。
|
||||
retained_new_users: int
|
||||
retention_cohort: int = 0
|
||||
retention_rate: float | None = None
|
||||
retention_note: str
|
||||
|
||||
@@ -57,6 +60,24 @@ class DashboardPeriodComparison(BaseModel):
|
||||
average_saved_cents: int | None = None
|
||||
|
||||
|
||||
class DashboardPeriodCoupon(BaseModel):
|
||||
"""领券核心数据(2026-07-05 产品新增)。点位=一张券(coupon_claim_record 一天一条终态);
|
||||
成功口径 success+already_claimed(与「我的」页累计领券一致)。"""
|
||||
|
||||
started: int = 0
|
||||
# 全部领成功的次数:completed 且当日该设备全部点位成功
|
||||
all_success: int = 0
|
||||
success_rate: float | None = None
|
||||
# 本期 session 触达的点位中成功的条数(同设备同日去重)
|
||||
point_success: int = 0
|
||||
# 每次发起的应领点位数(本期完成场实际点位数的众数;无完成场为空)
|
||||
points_per_session: int | None = None
|
||||
# 点位成功率 = point_success / (started × points_per_session);未跑到的点位计入分母视为失败
|
||||
point_success_rate: float | None = None
|
||||
# 耗时中位数(仅 completed 的 elapsed_ms,同「领券数据」页口径)
|
||||
median_elapsed_ms: int | None = None
|
||||
|
||||
|
||||
class DashboardPeriodCoins(BaseModel):
|
||||
granted_total: int
|
||||
reward_video_coin_total: int = 0
|
||||
@@ -85,6 +106,7 @@ class DashboardPeriod(BaseModel):
|
||||
date_to: date
|
||||
users: DashboardPeriodUsers
|
||||
comparison: DashboardPeriodComparison
|
||||
coupon: DashboardPeriodCoupon = DashboardPeriodCoupon()
|
||||
coins: DashboardPeriodCoins
|
||||
cash: DashboardPeriodCash
|
||||
trend: list[DashboardTrendPoint] = []
|
||||
|
||||
@@ -62,3 +62,16 @@ class OpsSavingsFeedPreviewItem(BaseModel):
|
||||
class OpsSavingsFeedPreviewOut(BaseModel):
|
||||
"""运营预览:实际混播出来的 feed(真实记录会插队,与客户端一致)。"""
|
||||
items: list[OpsSavingsFeedPreviewItem]
|
||||
|
||||
|
||||
class OpsRealRecordItem(BaseModel):
|
||||
masked_user: str # 脱敏后展示名(与 app 一致)
|
||||
saved_amount_cents: int # 节省金额(分)
|
||||
created_at: str # 比价记录时间(YYYY-MM-DD HH:MM)
|
||||
user_id: int # 真实 user_id(供运营核对,不下发客户端)
|
||||
|
||||
|
||||
class OpsRealRecordsOut(BaseModel):
|
||||
"""分页浏览「全部可展示的真实记录」(success+省>0,不去重、稳定顺序)。"""
|
||||
items: list[OpsRealRecordItem]
|
||||
total: int # 满足条件的真实记录总数(算页数用)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""admin RBAC 角色 + 权限目录 schemas。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PermissionPage(BaseModel):
|
||||
key: str
|
||||
label: str
|
||||
|
||||
|
||||
class PermissionGroup(BaseModel):
|
||||
group: str
|
||||
pages: list[PermissionPage]
|
||||
|
||||
|
||||
class RoleOut(BaseModel):
|
||||
id: int
|
||||
name: str # 角色 key(承重,不可变)
|
||||
label: str # 展示名(UI 显示)
|
||||
pages: list[str] # 有效可见页 key(super_admin = 全部页)
|
||||
is_builtin: bool # 内建角色(super_admin):不可编辑/删除
|
||||
in_use: int # 使用该角色的管理员数(删除前端提示 / 拦截用)
|
||||
|
||||
|
||||
class RoleCreateRequest(BaseModel):
|
||||
# 新增自定义角色:name 即用户输入的名称(同时作 key 与展示名)
|
||||
name: str = Field(..., min_length=1, max_length=32)
|
||||
pages: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RoleUpdateRequest(BaseModel):
|
||||
# 只改展示名 + 可见页;key 不可变
|
||||
label: str | None = Field(None, min_length=1, max_length=32)
|
||||
pages: list[str] | None = None
|
||||
@@ -20,6 +20,9 @@ class AdminUserListItem(BaseModel):
|
||||
wechat_nickname: str | None = None
|
||||
created_at: datetime
|
||||
last_login_at: datetime
|
||||
# 最近活跃 = max(最近登录, 最近发起比价, 最近发起领券);列表页由 queries._attach_last_active
|
||||
# 瞬态挂上。其他复用本 schema 的入口(用户 360 等)没挂该属性 → None(前端显示 '-')。
|
||||
last_active_at: datetime | None = None
|
||||
|
||||
|
||||
class AdminUserOverview(BaseModel):
|
||||
|
||||
@@ -130,6 +130,7 @@ class WithdrawBulkResult(BaseModel):
|
||||
|
||||
class WithdrawLedgerCheckOut(BaseModel):
|
||||
ok: bool
|
||||
# 普通现金账(coin_cash:金币兑换的现金)
|
||||
cash_balance_total_cents: int
|
||||
cash_transaction_total_cents: int
|
||||
balance_diff_cents: int
|
||||
@@ -137,6 +138,14 @@ class WithdrawLedgerCheckOut(BaseModel):
|
||||
missing_refund_txn_count: int
|
||||
duplicate_refund_txn_count: int
|
||||
refund_txn_on_non_terminal_count: int
|
||||
# 邀请奖励金账(invite_cash:与普通现金物理隔离,各自对账)。默认 0 向后兼容。
|
||||
invite_cash_balance_total_cents: int = 0
|
||||
invite_cash_transaction_total_cents: int = 0
|
||||
invite_balance_diff_cents: int = 0
|
||||
invite_missing_withdraw_txn_count: int = 0
|
||||
invite_missing_refund_txn_count: int = 0
|
||||
invite_duplicate_refund_txn_count: int = 0
|
||||
invite_refund_txn_on_non_terminal_count: int = 0
|
||||
|
||||
|
||||
class WxpayHealthCheckOut(BaseModel):
|
||||
|
||||
@@ -286,6 +286,7 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm
|
||||
ad_session_id=payload.ad_session_id,
|
||||
adn=payload.adn, slot_id=payload.slot_id,
|
||||
feed_scene=payload.feed_scene,
|
||||
trace_id=payload.trace_id,
|
||||
app_env=payload.app_env, our_code_id=payload.our_code_id,
|
||||
)
|
||||
logger.info(
|
||||
|
||||
+16
-1
@@ -6,13 +6,18 @@ POST /api/v1/analytics/events — 批量接收新手引导(及后续)埋点,appe
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.api.deps import DbSession
|
||||
from app.repositories import analytics as analytics_repo
|
||||
from app.repositories import analytics_selfstat as selfstat_repo
|
||||
from app.schemas.analytics import AnalyticsBatchIn, AnalyticsIngestOut
|
||||
from app.schemas.analytics_selfstat import SelfStatBatchIn, SelfStatIngestOut
|
||||
|
||||
router = APIRouter(prefix="/api/v1/analytics", tags=["analytics"])
|
||||
logger = logging.getLogger("shagua.analytics")
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
@@ -29,3 +34,13 @@ def ingest_events(
|
||||
) -> AnalyticsIngestOut:
|
||||
n = analytics_repo.record_batch(db, batch, client_ip=_client_ip(request))
|
||||
return AnalyticsIngestOut(received=n)
|
||||
|
||||
|
||||
@router.post("/selfstat", response_model=SelfStatIngestOut, summary="上报自报计数快照")
|
||||
def ingest_selfstat(batch: SelfStatBatchIn, db: DbSession) -> SelfStatIngestOut:
|
||||
try:
|
||||
snap_id = selfstat_repo.record_selfstat(db, batch)
|
||||
except Exception: # noqa: BLE001 — 计数链路要稳,落库失败不裸奔 500,记日志回明确错误
|
||||
logger.exception("selfstat ingest failed device=%s epoch=%s", batch.device_id, batch.epoch_id)
|
||||
raise HTTPException(status_code=503, detail="selfstat ingest failed") from None
|
||||
return SelfStatIngestOut(snapshot_id=snap_id)
|
||||
|
||||
+10
-1
@@ -81,7 +81,16 @@ def _record_claims_blocking(
|
||||
device_id: str, user_id: int | None, trace_id: str | None, results: list[dict]
|
||||
) -> None:
|
||||
with SessionLocal() as db:
|
||||
coupon_repo.record_claims(db, device_id, user_id, trace_id, results)
|
||||
# 取本次 session 环境,给 coupon_claim_record 打 app_env 标(每券成功率表按它过滤;设计 §13)。
|
||||
app_env = coupon_repo.session_app_env(db, trace_id)
|
||||
coupon_repo.record_claims(db, device_id, user_id, trace_id, results, app_env=app_env)
|
||||
# 顺带把本帧「成功平台」并入 coupon_session.platform_success(admin 领券数据 ②整单/③点位成功率;
|
||||
# 设计 route B,见 docs/guides/领券成功率指标-设计与埋点.md)。复用同一 SessionLocal、紧接 record_claims,
|
||||
# 不新增连接;并集幂等(无新平台不写),trace_id 缺失或 session 行未落库则跳过。
|
||||
if trace_id:
|
||||
coupon_repo.merge_session_platform_success(
|
||||
db, trace_id, coupon_repo.succeeded_platforms(results)
|
||||
)
|
||||
|
||||
|
||||
def _mark_completed_blocking(
|
||||
|
||||
+41
-4
@@ -25,9 +25,19 @@ from app.schemas.meituan import (
|
||||
ReferralLinkResponse,
|
||||
TopSalesRequest,
|
||||
)
|
||||
from app.utils.meituan_city import get_meituan_city
|
||||
|
||||
logger = logging.getLogger("shagua.meituan")
|
||||
|
||||
|
||||
def _resolve_city_id(latitude: float, longitude: float) -> str:
|
||||
"""经纬度 → 美团城市 ID;解析失败返 ""(调用方应降级返空)。"""
|
||||
try:
|
||||
return get_meituan_city(latitude, longitude).get("city_id", "")
|
||||
except Exception:
|
||||
logger.exception("get_meituan_city 失败")
|
||||
return ""
|
||||
|
||||
router = APIRouter(prefix="/api/v1/meituan", tags=["meituan-cps"])
|
||||
|
||||
|
||||
@@ -175,13 +185,19 @@ def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse:
|
||||
status = "degraded" if (not cards and wm_fail and dd_fail) else ("ok" if cards else "empty")
|
||||
return FeedResponse(items=cards, has_next=wm_hn or dd_hn, page=req.page, status=status)
|
||||
|
||||
# 智能推荐(rec):走【离线库】筛佣金率 ≥ 3%,分页返回(SQL 侧去重+排序+分页,秒级、不打美团)。
|
||||
# 智能推荐(rec):走【离线库】筛佣金率 ≥ 3%,按城市过滤,分页返回(SQL 侧去重+排序+分页,秒级、不打美团)。
|
||||
# 实测库里佣金≥3% 去重后仅 ~578 条(几乎全是外卖;到店团购佣金普遍 <3%):实时按"同城热销榜单"
|
||||
# 拉既撞限流、又填不满(该榜单中位佣金 ~0.8%,筛完每页剩 0-1 条),故从库出。佣金阈值逻辑不变。
|
||||
if tab == "rec":
|
||||
city_id = _resolve_city_id(lat, lon)
|
||||
if not city_id:
|
||||
return FeedResponse(items=[], has_next=False, page=req.page, status="degraded")
|
||||
PAGE = 20
|
||||
try:
|
||||
base = select(MeituanCoupon).where(MeituanCoupon.commission_percent >= 3.0)
|
||||
base = select(MeituanCoupon).where(
|
||||
MeituanCoupon.commission_percent >= 3.0,
|
||||
MeituanCoupon.city_id == city_id,
|
||||
)
|
||||
deduped = base.distinct(MeituanCoupon.dedup_key).order_by(
|
||||
MeituanCoupon.dedup_key,
|
||||
MeituanCoupon.commission_percent.desc(),
|
||||
@@ -211,6 +227,9 @@ def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse:
|
||||
card.distance_text = None
|
||||
card.distance_meters = None
|
||||
cards.append(card)
|
||||
if not cards and req.page == 1:
|
||||
# 命中城市却 0 券:该城确无 ≥3% 券,或 ETL 灌的 city_id 与 city_dict 口径不一致。
|
||||
logger.info("[feed] rec city_id=%s 命中 0 券(该城确无券?或 ETL/city_dict 的 city_id 口径不一致)", city_id)
|
||||
return FeedResponse(items=cards, has_next=has_next, page=req.page,
|
||||
status="ok" if cards else "empty")
|
||||
|
||||
@@ -254,14 +273,24 @@ def referral_link(req: ReferralLinkRequest) -> ReferralLinkResponse:
|
||||
|
||||
|
||||
@router.post("/top-sales", response_model=CouponListResponse,
|
||||
summary="销量最高(从离线库 meituan_coupon 按销量降序 + 跨源去重,不实时打美团)")
|
||||
summary="销量最高(从离线库 meituan_coupon 按销量降序 + 跨源去重,按城市过滤,不实时打美团)")
|
||||
def top_sales(req: TopSalesRequest, db: Session = Depends(get_db)) -> CouponListResponse:
|
||||
# 按设备经纬度定位城市,只查同城券;老客户端不带坐标 → 降级返空(不 422、不误返全城)。
|
||||
if req.latitude is None or req.longitude is None:
|
||||
return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded")
|
||||
city_id = _resolve_city_id(req.latitude, req.longitude)
|
||||
if not city_id:
|
||||
return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded")
|
||||
|
||||
# 去重 + 排序 + 分页全在 SQL 做,每页只取并解析当前页 ~20 条。
|
||||
# (之前实现每翻一页都全表拉取 + 全量 from_raw 解析,翻页慢 → 客户端滑动卡顿/翻不动。)
|
||||
# 库为空(prod 刚部署 / ETL 未跑完)时返空 + status=empty,不崩;库查询异常降级 degraded。
|
||||
try:
|
||||
# 1) DISTINCT ON (dedup_key):每个去重键(品牌|名|价)只留销量最高那条(同销量再按佣金)
|
||||
base = select(MeituanCoupon).where(MeituanCoupon.sale_volume_num.isnot(None))
|
||||
base = select(MeituanCoupon).where(
|
||||
MeituanCoupon.sale_volume_num.isnot(None),
|
||||
MeituanCoupon.city_id == city_id,
|
||||
)
|
||||
if req.platform is not None:
|
||||
base = base.where(MeituanCoupon.platform == req.platform)
|
||||
deduped = base.distinct(MeituanCoupon.dedup_key).order_by(
|
||||
@@ -292,6 +321,14 @@ def top_sales(req: TopSalesRequest, db: Session = Depends(get_db)) -> CouponList
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
if card.product_view_sign:
|
||||
# 不显示距离:库里的距离是相对城市默认点的(对用户无意义、且误导)。
|
||||
# 置空后前端"距离 店名"那行只剩店名、自动顶到最左(店名移到原距离的位置)。
|
||||
# 逻辑与推荐流保持一致
|
||||
card.distance_text = None
|
||||
card.distance_meters = None
|
||||
cards.append(card)
|
||||
if not cards and req.page == 1:
|
||||
# 命中城市却 0 券:可能该城确无券,也可能 ETL 灌的 city_id 与 city_dict 口径不一致(静默降级的隐患)。
|
||||
logger.info("[top-sales] city_id=%s 命中 0 券(该城确无券?或 ETL/city_dict 的 city_id 口径不一致)", city_id)
|
||||
return CouponListResponse(items=cards, has_next=has_next, search_id=None,
|
||||
status="ok" if cards else "empty")
|
||||
|
||||
+14
-2
@@ -43,6 +43,7 @@ from app.schemas.welfare import (
|
||||
WithdrawRequest,
|
||||
WithdrawResultOut,
|
||||
WithdrawStatusOut,
|
||||
WithdrawTierOut,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.wallet")
|
||||
@@ -173,8 +174,15 @@ def unbind_wechat(
|
||||
return UnbindWechatResultOut(bound=False)
|
||||
|
||||
|
||||
@router.get("/withdraw-info", response_model=WithdrawInfoOut, summary="提现额度/绑定状态/免确认开关")
|
||||
def withdraw_info(user: CurrentUser, db: DbSession) -> WithdrawInfoOut:
|
||||
@router.get("/withdraw-info", response_model=WithdrawInfoOut, summary="提现额度/绑定状态/免确认开关/档位")
|
||||
def withdraw_info(
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
source: str = Query(
|
||||
"coin_cash",
|
||||
description="提现账户:coin_cash(福利页,下发 tiers 档位) / invite_cash(邀请页,tiers 为空走旧逻辑)",
|
||||
),
|
||||
) -> WithdrawInfoOut:
|
||||
u = db.get(User, user.id)
|
||||
# 顺带同步免确认授权状态(捕获首单确认后已生效的授权 pending→active),让开关展示实时
|
||||
auth = crud_wallet.sync_transfer_auth(db, user.id)
|
||||
@@ -185,6 +193,7 @@ def withdraw_info(user: CurrentUser, db: DbSession) -> WithdrawInfoOut:
|
||||
wechat_nickname=u.wechat_nickname if u else None,
|
||||
wechat_avatar_url=u.wechat_avatar_url if u else None,
|
||||
transfer_auth_enabled=bool(auth and auth.state == "active"),
|
||||
tiers=[WithdrawTierOut(**t) for t in crud_wallet.withdraw_tier_states(db, user.id, source)],
|
||||
)
|
||||
|
||||
|
||||
@@ -218,6 +227,9 @@ def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> Withdraw
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="已有提现申请正在审核或打款中,请处理完成后再申请",
|
||||
) from e
|
||||
except crud_wallet.WithdrawTierUnavailableError as e:
|
||||
# 福利页档位闸(7-9):次数满/已选其他额度。正常客户端已按 tiers 预拦,此处兜底防绕过。
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="今日额度已达上限") from e
|
||||
except crud_wallet.InsufficientCashError as e:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="现金余额不足") from e
|
||||
|
||||
|
||||
@@ -11,7 +11,10 @@ from typing import Any
|
||||
|
||||
from app.core import rewards as r
|
||||
|
||||
# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int / bool
|
||||
# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int / bool / enum
|
||||
# hidden=True:仍是合法可配项(业务照常 get_value / admin 可经专用端点读写),但**不在通用
|
||||
# 「系统配置」页渲染**(admin/routers/config.py:list_config 按此过滤)。用于把已下线/已改由
|
||||
# 专用页管理的项从福利页 Tab 收起,同时保留后端默认值与写入能力。
|
||||
CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
||||
"signin_rewards": {
|
||||
"default": list(r.SIGNIN_REWARDS), "label": "签到 7 天金币档位",
|
||||
@@ -30,18 +33,21 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
||||
"default": r.WITHDRAW_MAX_CENTS, "label": "提现最高额(分)",
|
||||
"group": "钱包", "type": "int",
|
||||
},
|
||||
# 从福利页 Tab 收起(hidden)的项:任务 / 里程碑 整组 + 看广告组里的「单次金币(遗留展示值)/
|
||||
# 每轮次数 / 比价领券信息流广告开关 comparing_ad_enabled」。key 与默认值保留、后端业务照常读取,
|
||||
# 仅不在配置页渲染。看广告组保留可见的:每日上限 / 单次金币上限 / 关闭后冷却。
|
||||
"task_rewards": {
|
||||
"default": dict(r.TASK_REWARDS), "label": "一次性任务奖励",
|
||||
"group": "任务", "type": "dict_str_int", "help": "task_key → 金币。",
|
||||
"group": "任务", "type": "dict_str_int", "help": "task_key → 金币。", "hidden": True,
|
||||
},
|
||||
"record_milestones": {
|
||||
"default": list(r.RECORD_MILESTONES), "label": "比价里程碑金币档位",
|
||||
"group": "里程碑", "type": "int_list",
|
||||
"group": "里程碑", "type": "int_list", "hidden": True,
|
||||
"help": "累计成功比价第 1~N 档解锁发的金币。",
|
||||
},
|
||||
"ad_reward_coin": {
|
||||
"default": r.AD_REWARD_COIN, "label": "看广告单次金币",
|
||||
"group": "看广告", "type": "int",
|
||||
"group": "看广告", "type": "int", "hidden": True,
|
||||
"help": "历史兼容/测试展示值;正式发放按 eCPM 公式计算。",
|
||||
},
|
||||
"ad_daily_limit": {
|
||||
@@ -54,7 +60,7 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
||||
},
|
||||
"ad_round_count": {
|
||||
"default": r.VIDEO_ROUND_REQUIRED_COUNT, "label": "每轮看广告次数",
|
||||
"group": "看广告", "type": "int", "help": "当前为 1,表示每次广告关闭后触发短冷却。",
|
||||
"group": "看广告", "type": "int", "hidden": True, "help": "当前为 1,表示每次广告关闭后触发短冷却。",
|
||||
},
|
||||
"ad_cooldown_sec": {
|
||||
"default": r.VIDEO_ROUND_COOLDOWN_SECONDS, "label": "广告关闭后冷却(秒)",
|
||||
@@ -67,7 +73,7 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
||||
},
|
||||
"comparing_ad_enabled": {
|
||||
"default": True, "label": "比价/领券期信息流广告",
|
||||
"group": "看广告", "type": "bool",
|
||||
"group": "看广告", "type": "bool", "hidden": True,
|
||||
"help": (
|
||||
"开启后,比价进行中 + 领券等候期会在悬浮窗展示穿山甲信息流广告(变现行为);"
|
||||
"关闭则全程不出广告。客户端按 app 启动 / 每场比价开始时拉取并缓存,故为「最终一致」的"
|
||||
@@ -84,4 +90,10 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
||||
"提现页「批量对账」手动按钮不受影响。"
|
||||
),
|
||||
},
|
||||
# 首页轮播数据源(hidden:不进通用配置 Tab,由「首页轮播种子」页的专用端点 /marquee-seeds/mode 读写)。
|
||||
"marquee_feed_mode": {
|
||||
"default": "mixed", "label": "首页轮播数据源",
|
||||
"group": "首页轮播", "type": "enum", "hidden": True,
|
||||
"help": "mixed=真实优先+种子补位(默认);real=只用真实比价记录;seed=只用种子/合成(演示)。",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import NamedTuple
|
||||
|
||||
# 业务时区:签到的"今天"按北京时间算,不能用 UTC。
|
||||
# 否则 UTC+8 的凌晨 0~8 点会被算成 UTC 的前一天,导致签到日期错位。
|
||||
@@ -52,6 +53,30 @@ WITHDRAW_MIN_CENTS: int = 10
|
||||
WITHDRAW_MAX_CENTS: int = 5_000_000 # 5 万元
|
||||
|
||||
|
||||
# ===== 提现档位(福利页 coin_cash;7-9 对齐原型 withdrawal.html)=====
|
||||
# 后端是档位唯一真相源:withdraw-info 按此下发,create_withdraw 按此校验(防绕过客户端刷)。
|
||||
# 规则(2026-07-09 拍板):
|
||||
# - 新人档(is_newbie):账号历史一次性,"发起就算用过"(任意状态含被拒),用过即不再下发;
|
||||
# 0.1 与 0.3 各自独立同天可各提一次,且不参与常规档"每日选一个额度"互斥。
|
||||
# - 常规档:按北京日计次(0.5×3 / 10×1 / 20×1),三档每天只能选一个。
|
||||
# invite_cash(邀请页)本轮无档位概念,不在此表。改档位=改这里发版。
|
||||
class WithdrawTier(NamedTuple):
|
||||
amount_cents: int
|
||||
label: str # 客户端档位方块展示文案
|
||||
badge: str | None # 角标文案;None=无角标
|
||||
daily_limit: int # 每日次数上限(新人档的"历史一次性"另由 is_newbie 判定)
|
||||
is_newbie: bool
|
||||
|
||||
|
||||
WITHDRAW_TIERS_COIN_CASH: tuple[WithdrawTier, ...] = (
|
||||
WithdrawTier(10, "0.1", "新人福利", 1, True),
|
||||
WithdrawTier(30, "0.3", "新人福利", 1, True),
|
||||
WithdrawTier(50, "0.5", None, 3, False),
|
||||
WithdrawTier(1000, "10", None, 1, False),
|
||||
WithdrawTier(2000, "20", None, 1, False),
|
||||
)
|
||||
|
||||
|
||||
# ===== 一次性任务(领一次,user_task 去重)=====
|
||||
TASK_ENABLE_NOTIFICATION = "enable_notification"
|
||||
|
||||
|
||||
@@ -71,6 +71,12 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
settings.DATABASE_URL.split("://", 1)[0],
|
||||
)
|
||||
get_pricebot_client() # 预热透传 client:把建 SSL 上下文的一次性成本付在启动,首个领券请求即热
|
||||
try:
|
||||
# 预热离线地理库:首次加载 ~2.5M 行 CSV + 建 KDTree,摊到启动、不砸首个按城市过滤的请求
|
||||
from app.utils import geo
|
||||
geo.ensure_loaded()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("reverse_geocoder 预热失败(城市反查将在首个请求时懒加载)")
|
||||
reconcile_task = start_withdraw_reconcile_worker()
|
||||
heartbeat_task = start_heartbeat_monitor()
|
||||
daily_exchange_task = start_daily_exchange_worker()
|
||||
|
||||
@@ -5,7 +5,12 @@ from app.models.ad_pangle_revenue import AdPangleDailyRevenue # noqa: F401
|
||||
from app.models.ad_reward import AdRewardRecord # noqa: F401
|
||||
from app.models.ad_watch_log import AdWatchLog # noqa: F401
|
||||
from app.models.admin import AdminAuditLog, AdminUser # noqa: F401
|
||||
from app.models.admin_role import AdminRole # noqa: F401
|
||||
from app.models.analytics_event import AnalyticsEvent # noqa: F401
|
||||
from app.models.analytics_selfstat import ( # noqa: F401
|
||||
AnalyticsSelfStat,
|
||||
AnalyticsSelfStatEvent,
|
||||
)
|
||||
from app.models.app_config import AppConfig # noqa: F401
|
||||
from app.models.comparison import ComparisonRecord # noqa: F401
|
||||
from app.models.cps_activity import CpsActivity # noqa: F401
|
||||
|
||||
@@ -32,6 +32,9 @@ class AdEcpmRecord(Base):
|
||||
# 点位场景:comparison(比价) / coupon(领券) / welfare(福利),供收益报表区分比价/领券 Draw 收益;
|
||||
# 仅信息流/Draw 上报(比价与领券共用同一代码位,只能客户端各调用点显式打标),激励视频为 NULL。
|
||||
feed_scene: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
# 本次比价/领券 trace_id(信息流场景客户端带上):把这条展示收益归属到对应比价/领券记录。
|
||||
# 领券数据 / 比价记录看板按 trace_id 聚合"本次广告收益"。激励视频/福利/旧客户端 = NULL。
|
||||
trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
# 客户端生成的一次广告会话 id;激励视频 S2S 回调 extra 会透传同值
|
||||
ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
# 实际投放的 ADN(穿山甲 getShowEcpm().getSdkName(),如 pangle / gdt)
|
||||
|
||||
+7
-1
@@ -25,8 +25,14 @@ class AdminUser(Base):
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
# super_admin(全权+管账号)/ finance(钱:提现+金币)/ operator(用户+反馈+大盘)
|
||||
# 明文登录密码:仅「后台 UI 创建/重置」的管理员留存,供超管在权限管理页复看转交。
|
||||
# 脚本/起后台时建的超管账号不写(为 None → 前端「不显示密码」)。⚠️ 内部工具便利取舍,见 create/list。
|
||||
plain_password: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# super_admin(全权+管账号)/ finance(钱:提现+金币)/ operator(用户+反馈+大盘)/ custom(按人自定义)
|
||||
role: Mapped[str] = mapped_column(String(20), nullable=False, default="operator")
|
||||
# 「自定义」权限:仅当 role == "custom" 时有效,存这个人专属的可见页 key 列表(不共享给他人)。
|
||||
# 非 custom 用户为 None → 可见页跟随角色。登录/`/me` 下发有效页时,非空即优先用它(见 auth._admin_out_with_pages)。
|
||||
pages_override: Mapped[list | None] = mapped_column(_JSON, nullable=True)
|
||||
# active / disabled
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""admin 角色 → 可见页面(权限)映射表。
|
||||
|
||||
RBAC 的「角色」侧:每个角色持有一组「页面 key」(= 左侧导航项),决定该角色登录后台后左边能看到
|
||||
哪些页。super_admin 是内建全权角色(is_builtin=True),恒可见全部页(effective_pages 里特判,
|
||||
不依赖本表存的 pages)、不可编辑/删除。其余角色(含 operator/finance)可由 super_admin 增删改。
|
||||
|
||||
admin_user.role 存的是本表的 name(字符串弱引用,不建外键——与既有 require_role 字符串口径一致;
|
||||
删除在用角色由业务层拦截,见 routers/roles.py)。页面 key 清单见 app/admin/permissions.py。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
_JSON = JSON().with_variant(JSONB(), "postgresql")
|
||||
|
||||
|
||||
class AdminRole(Base):
|
||||
__tablename__ = "admin_role"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
# 角色标识 key(不可变,承重):admin_user.role 引用它、require_role 按它鉴权。
|
||||
# 内建 = super_admin/operator/finance/tech(英文,勿改);自定义 = 创建时的名称。
|
||||
name: Mapped[str] = mapped_column(String(32), unique=True, index=True, nullable=False)
|
||||
# 展示名(可改):内建 = 管理员/运营/财务/技术;自定义默认 = name。UI 一律展示 label。
|
||||
label: Mapped[str] = mapped_column(String(32), nullable=False, default="")
|
||||
# 该角色可见页面 key 列表(= 左侧导航项),见 app/admin/permissions.py 的 PERMISSION_CATALOG
|
||||
pages: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
|
||||
# 内建角色(当前仅 super_admin):不可编辑/删除,恒全权
|
||||
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<AdminRole name={self.name} pages={len(self.pages)} builtin={self.is_builtin}>"
|
||||
@@ -0,0 +1,59 @@
|
||||
"""埋点/上报成功率自报计数快照表(append-only)。
|
||||
|
||||
客户端周期上报「自 epoch 起算的累计计数」;服务端只存原始快照,查询时在 Python 侧差分聚合
|
||||
(见 app/admin/repositories/analytics_health.py)。与既有 analytics_event 表完全独立。
|
||||
|
||||
- analytics_selfstat :一快照一行(快照头 + 设备维度 + 设备级诊断量)
|
||||
- analytics_selfstat_event :一 event 一行(四类累计计数),外键指向快照头
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class AnalyticsSelfStat(Base):
|
||||
__tablename__ = "analytics_selfstat"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
device_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||
epoch_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||
# 设备维度(每设备固定,下钻用)
|
||||
app_ver: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
oem: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
os: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
# 设备级诊断量(累计;queue_depth 是瞬时 gauge)
|
||||
batches_attempted: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
batches_ok: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
batches_fail: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
retries: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
queue_depth: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
sent_at: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # 端上报时刻 epoch ms
|
||||
# 服务端接收时间(权威,用于时间分桶与分区排序)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<AnalyticsSelfStat id={self.id} device={self.device_id} epoch={self.epoch_id}>"
|
||||
|
||||
|
||||
class AnalyticsSelfStatEvent(Base):
|
||||
__tablename__ = "analytics_selfstat_event"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
snapshot_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("analytics_selfstat.id"), index=True, nullable=False
|
||||
)
|
||||
event: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||
attempted: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
drop_capture: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
delivered: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
drop_undelivered: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<AnalyticsSelfStatEvent snap={self.snapshot_id} event={self.event}>"
|
||||
@@ -86,6 +86,10 @@ class ComparisonRecord(Base):
|
||||
|
||||
# ===== 订单概要 =====
|
||||
store_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# 下单商品名拼接串(顿号分隔,从 items[].name 去重派生),供 admin 列表「商品」列展示 + 商品名搜索。
|
||||
# items 是 JSON(SQLite 下 json.dumps ensure_ascii 把中文转义,无法直接 CAST+LIKE),故另派生成普通
|
||||
# 文本列——跨库 LIKE 一致、可加索引。写路径(upsert_record / harvest_done)落库时同步派生。
|
||||
product_names: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
total_dish_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
skipped_dish_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
|
||||
@@ -66,6 +66,9 @@ class CouponClaimRecord(Base):
|
||||
|
||||
# success / already_claimed / failed / skipped(原样取 pricebot coupon 结果)
|
||||
status: Mapped[str] = mapped_column(String(24), nullable=False)
|
||||
# 领券所属 session 的环境 prod/dev(/step 按 trace_id 查 coupon_session.app_env 打标)。
|
||||
# 旧行 NULL(不回填)。admin「按券成功率」表据此过滤环境。见设计 §13。
|
||||
app_env: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True)
|
||||
vendor: Mapped[str | None] = mapped_column(String(48), nullable=True)
|
||||
coupon_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# 这张领到几张(pricebot display_count;给不出时为 None)
|
||||
@@ -240,6 +243,10 @@ class CouponSession(Base):
|
||||
platform_elapsed: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
|
||||
# 领到总张数(收尾帧带)。
|
||||
claimed_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# 本次 session 至少领到一张(status∈{success,already_claimed})的平台 id 列表,如 ["meituan-waimai","jd-waimai"]。
|
||||
# admin「领券数据」据此算整单成功率(②)/点位成功率(③);服务端 /step 逐帧按 trace_id 并集写入
|
||||
# (见 coupon_state.merge_session_platform_success)。旧行=NULL → 视作空集。
|
||||
platform_success: Mapped[list | None] = mapped_column(_JSON, nullable=True)
|
||||
# pricebot done 帧回传的公网调试链接(price.shaguabijia.com/traces/{dir});含落盘时分秒、拼不出,只能存
|
||||
# (同 ComparisonRecord.trace_url)。admin「领券数据」明细据此渲染可点 trace 链接;未到 done(failed/abandoned)为空。
|
||||
trace_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
from app.core.rewards import cn_today
|
||||
from app.models.ad_ecpm import AdEcpmRecord
|
||||
|
||||
@@ -24,6 +25,7 @@ def create_ecpm_record(
|
||||
adn: str | None = None,
|
||||
slot_id: str | None = None,
|
||||
feed_scene: str | None = None,
|
||||
trace_id: str | None = None,
|
||||
app_env: str | None = None,
|
||||
our_code_id: str | None = None,
|
||||
) -> AdEcpmRecord:
|
||||
@@ -43,6 +45,7 @@ def create_ecpm_record(
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
feed_scene=feed_scene,
|
||||
trace_id=trace_id,
|
||||
app_env=app_env,
|
||||
our_code_id=our_code_id,
|
||||
ecpm_raw=ecpm_raw,
|
||||
@@ -105,3 +108,27 @@ def count_today(db: Session, user_id: int) -> int:
|
||||
AdEcpmRecord.report_date == cn_today().isoformat(),
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
|
||||
def revenue_yuan_by_trace(db: Session, trace_ids: list[str]) -> dict[str, float]:
|
||||
"""各 trace_id 的广告预估收益(元):按 trace_id 聚合 ad_ecpm_record 的展示收益。
|
||||
|
||||
单条展示收益 = min(eCPM元, AD_ECPM_MAX_FEN/100) / 1000(与 admin 广告收益报表同口径)。
|
||||
ecpm_raw 是字符串且需逐条钳顶,故取回后 Python 求和(行数=本页各 trace 的展示条数,很小)。
|
||||
trace_id 仅信息流(比价/领券)场景客户端带,激励视频/旧数据为 NULL,按 trace_id 过滤天然只算对应场景。
|
||||
只喂**当前页**的 trace_id(≤ 一页条数);空集合直接返回(避免 IN () 非法)。
|
||||
"""
|
||||
if not trace_ids:
|
||||
return {}
|
||||
rows = db.execute(
|
||||
select(AdEcpmRecord.trace_id, AdEcpmRecord.ecpm_raw).where(
|
||||
AdEcpmRecord.trace_id.in_(trace_ids),
|
||||
)
|
||||
).all()
|
||||
cap_yuan = rewards.AD_ECPM_MAX_FEN / 100.0
|
||||
out: dict[str, float] = {}
|
||||
for tid, ecpm_raw in rows:
|
||||
if not tid:
|
||||
continue
|
||||
out[tid] = out.get(tid, 0.0) + min(rewards.parse_ecpm_yuan(ecpm_raw), cap_yuan) / 1000.0
|
||||
return {tid: round(v, 6) for tid, v in out.items()}
|
||||
|
||||
@@ -176,10 +176,19 @@ def grant_feed_reward(
|
||||
)
|
||||
return _commit_record(db, rec, client_event_id)
|
||||
|
||||
# 按点位场景拆流水文案(2026-07):比价等候期看的广告 vs 领券时看的广告,在收益明细里分开显示。
|
||||
# feed_scene=comparison→比价奖励 / coupon→领券奖励;其它(welfare/空/旧端不带)维持通用「信息流广告奖励」。
|
||||
# 客户端按此 biz_type 直显固定文案(见 CoinHistoryViewModel.coinTitle),故 remark 只作后台留痕/兜底。
|
||||
if feed_scene == "comparison":
|
||||
reward_biz, reward_remark = "feed_ad_reward_comparison", "比价奖励"
|
||||
elif feed_scene == "coupon":
|
||||
reward_biz, reward_remark = "feed_ad_reward_coupon", "领券奖励"
|
||||
else:
|
||||
reward_biz, reward_remark = "feed_ad_reward", "信息流广告奖励"
|
||||
crud_wallet.grant_coins(
|
||||
db, user_id, coin,
|
||||
biz_type="feed_ad_reward", ref_id=client_event_id,
|
||||
remark="信息流广告奖励",
|
||||
biz_type=reward_biz, ref_id=client_event_id,
|
||||
remark=reward_remark,
|
||||
)
|
||||
rec = AdFeedRewardRecord(
|
||||
client_event_id=client_event_id,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""自报计数快照落库。一次事务:插 1 条快照头 + N 条 event 行,返回快照 id。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.analytics_selfstat import AnalyticsSelfStat, AnalyticsSelfStatEvent
|
||||
from app.schemas.analytics_selfstat import SelfStatBatchIn
|
||||
|
||||
|
||||
def record_selfstat(db: Session, batch: SelfStatBatchIn) -> int:
|
||||
snap = AnalyticsSelfStat(
|
||||
device_id=batch.device_id,
|
||||
epoch_id=batch.epoch_id,
|
||||
app_ver=batch.app_ver,
|
||||
oem=batch.oem,
|
||||
os=batch.os,
|
||||
batches_attempted=batch.batches_attempted,
|
||||
batches_ok=batch.batches_ok,
|
||||
batches_fail=batch.batches_fail,
|
||||
retries=batch.retries,
|
||||
queue_depth=batch.queue_depth,
|
||||
sent_at=batch.sent_at,
|
||||
)
|
||||
db.add(snap)
|
||||
db.flush() # 拿到 snap.id
|
||||
db.add_all([
|
||||
AnalyticsSelfStatEvent(
|
||||
snapshot_id=snap.id,
|
||||
event=e.event,
|
||||
attempted=e.attempted,
|
||||
drop_capture=e.drop_capture,
|
||||
delivered=e.delivered,
|
||||
drop_undelivered=e.drop_undelivered,
|
||||
)
|
||||
for e in batch.events
|
||||
])
|
||||
db.commit()
|
||||
return snap.id
|
||||
@@ -24,6 +24,23 @@ def _yuan_to_cents(yuan: float | None) -> int | None:
|
||||
return round(yuan * 100)
|
||||
|
||||
|
||||
def _product_names_from_items(items: list | None) -> str | None:
|
||||
"""下单商品 items([{name, qty, specs?}])→ 顿号分隔的商品名串(去重保序),
|
||||
供 admin「商品」列展示 + 商品名 LIKE 搜索。空 / 无名 → None;超列宽(512)截断留余量。"""
|
||||
if not items:
|
||||
return None
|
||||
names: list[str] = []
|
||||
for it in items:
|
||||
name = it.get("name") if isinstance(it, dict) else None
|
||||
if not name:
|
||||
continue
|
||||
s = str(name).strip()
|
||||
if s and s not in names:
|
||||
names.append(s)
|
||||
joined = "、".join(names)
|
||||
return joined[:500] or None
|
||||
|
||||
|
||||
def _derive(payload: ComparisonRecordIn) -> dict:
|
||||
"""从上报 payload 派生结构化列(best/saved/is_source_best/status)。"""
|
||||
results = payload.comparison_results
|
||||
@@ -81,10 +98,12 @@ def upsert_record(
|
||||
新客户端不再 POST(改由 compare.py 透传壳 harvest 落库)。
|
||||
"""
|
||||
derived = _derive(payload)
|
||||
items = [it.model_dump(exclude_none=True) for it in payload.items]
|
||||
fields = dict(
|
||||
device_id=payload.device_id,
|
||||
business_type=payload.business_type,
|
||||
store_name=payload.store_name,
|
||||
product_names=_product_names_from_items(items),
|
||||
source_platform_id=payload.source_platform_id,
|
||||
source_platform_name=payload.source_platform_name,
|
||||
source_package=payload.source_package,
|
||||
@@ -93,7 +112,7 @@ def upsert_record(
|
||||
trace_url=payload.trace_url,
|
||||
total_dish_count=payload.total_dish_count,
|
||||
skipped_dish_count=payload.skipped_dish_count,
|
||||
items=[it.model_dump(exclude_none=True) for it in payload.items],
|
||||
items=items,
|
||||
comparison_results=[r.model_dump() for r in payload.comparison_results],
|
||||
skipped_dish_names=list(payload.skipped_dish_names),
|
||||
# 客户端环境 / 性能(debug,客户端上报;旧客户端为 None)
|
||||
@@ -288,6 +307,8 @@ def harvest_done(
|
||||
行不存在(理论上帧0已建;防御)则新建。"""
|
||||
results = done_params.get("comparison_results") or []
|
||||
derived = _derive_from_results(results)
|
||||
# 菜品:pricebot 已把源单菜品塞进 comparison_results[源行].items
|
||||
items = next((r.get("items") or [] for r in results if r.get("is_source")), [])
|
||||
fields = dict(
|
||||
business_type=business_type or "food",
|
||||
information=done_params.get("information") or None,
|
||||
@@ -298,8 +319,8 @@ def harvest_done(
|
||||
skipped_dish_count=done_params.get("skipped_dish_count"),
|
||||
skipped_dish_names=list(done_params.get("skipped_dish_names") or []),
|
||||
comparison_results=results,
|
||||
# 菜品:pricebot 已把源单菜品塞进 comparison_results[源行].items
|
||||
items=next((r.get("items") or [] for r in results if r.get("is_source")), []),
|
||||
items=items,
|
||||
product_names=_product_names_from_items(items),
|
||||
raw_payload=done_params,
|
||||
**derived,
|
||||
**_device_cols_from_info(device_info),
|
||||
|
||||
@@ -147,12 +147,22 @@ def reset_today_completion(db: Session, device_id: str) -> int:
|
||||
|
||||
# ===== 领券记录(coupon_claim_record)=====
|
||||
|
||||
def session_app_env(db: Session, trace_id: str | None) -> str | None:
|
||||
"""按 trace_id 取 coupon_session.app_env(每券成功率表打环境标用);无 trace_id / 查不到 → None。"""
|
||||
if not trace_id:
|
||||
return None
|
||||
return db.execute(
|
||||
select(CouponSession.app_env).where(CouponSession.trace_id == trace_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def record_claims(
|
||||
db: Session,
|
||||
device_id: str,
|
||||
user_id: int | None,
|
||||
trace_id: str | None,
|
||||
results: list[dict],
|
||||
app_env: str | None = None,
|
||||
) -> int:
|
||||
"""一批券领取结果幂等写入,返回写入(新增 + 更新)条数。
|
||||
|
||||
@@ -186,12 +196,15 @@ def record_claims(
|
||||
row.user_id = user_id
|
||||
if count is not None:
|
||||
row.claimed_count = count
|
||||
if app_env is not None:
|
||||
row.app_env = app_env
|
||||
row.extra = r
|
||||
else:
|
||||
db.add(CouponClaimRecord(
|
||||
device_id=device_id, user_id=user_id,
|
||||
coupon_id=coupon_id, claim_date=today,
|
||||
status=status, vendor=r.get("vendor"), coupon_name=r.get("name"),
|
||||
status=status, app_env=app_env,
|
||||
vendor=r.get("vendor"), coupon_name=r.get("name"),
|
||||
claimed_count=count, trace_id=trace_id, reason=r.get("reason"),
|
||||
extra=r,
|
||||
))
|
||||
@@ -235,6 +248,47 @@ def sum_claimed_count(db: Session, user_id: int) -> int:
|
||||
return int(total or 0)
|
||||
|
||||
|
||||
# ===== 领券平台推导(coupon_id → 平台;成功平台集)=====
|
||||
|
||||
# 成功语义:success + already_claimed 算成功(pricebot 代码 emit already_claimed,协议 enum 漏了);
|
||||
# failed / skipped 不算。与 sum_claimed_count 同口径。
|
||||
_SUCCESS_STATUSES = frozenset({"success", "already_claimed"})
|
||||
|
||||
# 三档平台 id 及固定序(美团→淘宝→京东),与客户端 DEFAULT_PLATFORM_ORDER 对齐。
|
||||
DEFAULT_PLATFORMS: tuple[str, ...] = ("meituan-waimai", "taobao-shanguang", "jd-waimai")
|
||||
|
||||
|
||||
def coupon_id_to_platform(coupon_id: str | None) -> str | None:
|
||||
"""coupon_id 前缀 → 平台 id;无法识别 / 空 → None。
|
||||
|
||||
与客户端 `CouponForegroundService.couponIdToPlatform` 同词表:
|
||||
mt_→美团外卖 / tb_·ele_·elm_→淘宝闪购 / jd_→京东外卖。
|
||||
"""
|
||||
if not coupon_id:
|
||||
return None
|
||||
if coupon_id.startswith("mt_"):
|
||||
return "meituan-waimai"
|
||||
if coupon_id.startswith(("tb_", "ele_", "elm_")):
|
||||
return "taobao-shanguang"
|
||||
if coupon_id.startswith("jd_"):
|
||||
return "jd-waimai"
|
||||
return None
|
||||
|
||||
|
||||
def succeeded_platforms(results: list[dict]) -> list[str]:
|
||||
"""一批券结果 → 至少领到一张的平台集(按 DEFAULT_PLATFORMS 去重保序)。
|
||||
|
||||
只取 status∈{success, already_claimed} 的券;失败/跳过、无法识别平台的券跳过。
|
||||
"""
|
||||
ok: set[str] = set()
|
||||
for r in results:
|
||||
if r.get("status") in _SUCCESS_STATUSES:
|
||||
platform = coupon_id_to_platform(r.get("coupon_id"))
|
||||
if platform is not None:
|
||||
ok.add(platform)
|
||||
return [p for p in DEFAULT_PLATFORMS if p in ok]
|
||||
|
||||
|
||||
# ===== 领券任务流水(coupon_session,admin「领券数据」看板数据源)=====
|
||||
|
||||
def upsert_coupon_session(
|
||||
@@ -321,3 +375,31 @@ def upsert_coupon_session(
|
||||
except IntegrityError:
|
||||
# 并发下另一请求刚插了同 trace_id → 唯一约束撞,回滚忽略(本就幂等)。
|
||||
db.rollback()
|
||||
|
||||
|
||||
def merge_session_platform_success(
|
||||
db: Session, trace_id: str, platforms: list[str]
|
||||
) -> None:
|
||||
"""把本帧「成功平台」并入 coupon_session.platform_success(按 trace_id,并集幂等,按 DEFAULT_PLATFORMS 保序)。
|
||||
|
||||
- 领券 /step 每逢带券结果的帧调一次(平台成败布尔,跨帧取并集天然幂等,不重复计)。
|
||||
- 读不到该 trace_id 的行 → **静默跳过**(不建兜底行;设计 §5:started 帧几乎必先落库)。
|
||||
- 并集无变化(该平台已记过)→ 不写库,省一次 UPDATE。
|
||||
- fire-and-forget:调用方已吞异常;并发唯一冲突回滚忽略。
|
||||
"""
|
||||
if not platforms:
|
||||
return
|
||||
row = db.execute(
|
||||
select(CouponSession).where(CouponSession.trace_id == trace_id)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
return
|
||||
merged = set(row.platform_success or []) | set(platforms)
|
||||
new_list = [p for p in DEFAULT_PLATFORMS if p in merged]
|
||||
if new_list == (row.platform_success or []):
|
||||
return # 幂等:无新平台,不写
|
||||
row.platform_success = new_list
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
|
||||
+149
-43
@@ -29,6 +29,18 @@ from app.core.rewards import CN_TZ
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.ops_marquee_seed import OpsMarqueeSeed
|
||||
from app.models.user import User
|
||||
from app.repositories import app_config
|
||||
from app.repositories.user import is_default_nickname
|
||||
|
||||
# 首页轮播数据源模式(存 app_config.marquee_feed_mode):
|
||||
# mixed=真实优先+种子补位+合成兜底(默认,原行为);real=只真实(不足则少/空);seed=只种子+合成兜底。
|
||||
FEED_MODES = ("mixed", "real", "seed")
|
||||
|
||||
|
||||
def get_feed_mode(db: Session) -> str:
|
||||
"""读首页轮播数据源模式;非法/未配置回退 mixed(= 原行为)。"""
|
||||
mode = app_config.get_value(db, "marquee_feed_mode")
|
||||
return mode if mode in FEED_MODES else "mixed"
|
||||
|
||||
# feed 运行时随机源(每次请求结果不同 = 轮播想要的「鲜活感」)
|
||||
_rng = random.Random()
|
||||
@@ -129,9 +141,12 @@ def _synth_masked_name(rng: random.Random) -> str:
|
||||
|
||||
def _mask_real(nickname: str | None, user_id: int) -> str:
|
||||
"""真实用户脱敏(对齐 PRD「用户标识打码规则」):设过昵称→昵称脱敏(中英文皆可);
|
||||
没昵称→「用户」+5星+id 后 2 位(用户*****08),按 user_id 稳定、刷新不变脸。"""
|
||||
没昵称→「用户」+5星+id 后 2 位(用户*****08),按 user_id 稳定、刷新不变脸。
|
||||
|
||||
创建时自动分配的默认昵称(「用户」+9 位随机,见 user.is_default_nickname)不算用户主动设的昵称,
|
||||
按「无昵称」处理走 id 规则(产品决策 2026-07:默认昵称归入「没昵称」档)。"""
|
||||
nick = (nickname or "").strip()
|
||||
if nick:
|
||||
if nick and not is_default_nickname(nick):
|
||||
return _mask_nickname(nick)
|
||||
return _mask_anon(user_id)
|
||||
|
||||
@@ -202,60 +217,79 @@ def _recent_real_rows(db: Session) -> list[tuple[int, int, str | None]]:
|
||||
return out
|
||||
|
||||
|
||||
def get_feed(db: Session, limit: int = 8) -> list[dict]:
|
||||
def _shuffle_declustered(rows: list, rng: random.Random | None = None) -> list:
|
||||
"""洗牌 + 「去连簇」:先洗牌,再贪心重排让相邻两条尽量不是同一 user_id(元素 [0] 即 user_id)。
|
||||
rng=None → 用全局 _rng(feed 每次新随机);传入 rng(如固定种子 Random)→ 排列确定(admin 稳定分页)。
|
||||
减少同一用户连续出现;只有少数几个用户时 best-effort。"""
|
||||
rng = rng or _rng
|
||||
pool = list(rows)
|
||||
rng.shuffle(pool)
|
||||
result: list = []
|
||||
while pool:
|
||||
prev_uid = result[-1][0] if result else None
|
||||
# 优先挑与上一条不同 user 的;挑不到(只剩同 user)才取第一个
|
||||
idx = next((i for i, r in enumerate(pool) if r[0] != prev_uid), 0)
|
||||
result.append(pool.pop(idx))
|
||||
return result
|
||||
|
||||
|
||||
def get_feed(db: Session, limit: int = 8, mode: str | None = None) -> list[dict]:
|
||||
"""返回最多 limit 条 {masked_user, saved_amount_cents, time(HH:MM:SS 北京)}。
|
||||
|
||||
真实条:success 且 0 < saved ≤ 上限,按 user 去重(同一用户只取最新一条,避免单人刷屏)。
|
||||
mode:显式传入(admin 预览指定模式)则用它、**不改持久化配置**;不传(客户端 /savings-feed)读
|
||||
持久化的 marquee_feed_mode;非法值一律回退到持久化模式。
|
||||
真实条:success 且 0 < saved ≤ 上限,**不按 user 去重**(打乱 + 去连簇:相邻尽量不同用户、减少单人连刷)后取前 limit。
|
||||
不足用启用的种子补齐——**公平随机抽取** need 个(而非固定取前 N),让所有种子都有机会露出;
|
||||
种子用户名留空则随机合成(避开撞名),金额取**长尾随机**(小额居多、偶尔大额,更像真实分布)。
|
||||
真实 + 种子仍不满 limit → 内置合成条**补满**,保证轮播既不空也不稀疏。
|
||||
展示时间统一「刷新」成相对现在的最近时刻(从 now 往前**随机抖动**递减),保证轮播永远像刚发生、
|
||||
节奏自然不机械(真实用户/金额不变,只换展示时间——避免旧测试数据 / 低谷期记录显示成过时时间)。
|
||||
"""
|
||||
# 真实条:取较多近期记录(带 ~30s 缓存)后按 user 去重;金额超上限的异常值已在查询剔除。
|
||||
rows = _recent_real_rows(db)
|
||||
# 预览可显式指定模式(所见=选中模式,不依赖 PATCH 落库时序);None/非法 → 读持久化配置。
|
||||
mode = mode if mode in FEED_MODES else get_feed_mode(db) # mixed / real / seed
|
||||
|
||||
items: list[dict] = []
|
||||
used_names: set[str] = set()
|
||||
seen_users: set[int] = set()
|
||||
for uid, sc, nick in rows:
|
||||
if uid in seen_users:
|
||||
continue
|
||||
seen_users.add(uid)
|
||||
name = _mask_real(nick, uid)
|
||||
used_names.add(name) # 真实名按昵称/id 稳定;偶发撞名可接受
|
||||
items.append({"masked_user": name, "saved_amount_cents": int(sc)})
|
||||
if len(items) >= limit:
|
||||
break
|
||||
|
||||
need = limit - len(items)
|
||||
if need > 0:
|
||||
seeds = db.execute(
|
||||
select(OpsMarqueeSeed).where(OpsMarqueeSeed.enabled.is_(True))
|
||||
).scalars().all()
|
||||
# 公平随机抽取 need 个(池子够大则不放回抽样;否则全用并打散),让所有启用种子都有机会露出。
|
||||
chosen = _rng.sample(seeds, need) if len(seeds) > need else list(seeds)
|
||||
_rng.shuffle(chosen)
|
||||
for s in chosen:
|
||||
# 用户名:旧的「用户****xxx」统一模板名 / 留空 → 一律走新混合合成(避开同屏撞名、自愈历史种子);
|
||||
# 仅运营手动设的非模板真名才原样用。
|
||||
fixed = (s.masked_user or "").strip()
|
||||
name = fixed if fixed and not fixed.startswith("用户****") else _unique_name(used_names)
|
||||
# 真实条(mixed / real):**不按 user 去重**——打乱 + 去连簇(相邻尽量不同用户、减少单人连刷)后取前
|
||||
# limit;金额超上限的异常值已在查询剔除。同一用户可多条露出,但被打散、尽量不连续。
|
||||
if mode != "seed":
|
||||
for uid, sc, nick in _shuffle_declustered(_recent_real_rows(db))[:limit]:
|
||||
name = _mask_real(nick, uid)
|
||||
used_names.add(name) # 同一用户可多条,名字重复无害(used_names 仅供种子避重)
|
||||
items.append({"masked_user": name, "saved_amount_cents": int(sc)})
|
||||
|
||||
# 种子补位 + 合成兜底(mixed / seed):mixed 下补真实不足的部分,seed 下全量用种子/合成。
|
||||
# real 模式**跳过**——只出真实,不掺任何假数据(真实不足则少于 limit,为 0 时返回空)。
|
||||
if mode != "real":
|
||||
need = limit - len(items)
|
||||
if need > 0:
|
||||
seeds = db.execute(
|
||||
select(OpsMarqueeSeed).where(OpsMarqueeSeed.enabled.is_(True))
|
||||
).scalars().all()
|
||||
# 公平随机抽取 need 个(池子够大则不放回抽样;否则全用并打散),让所有启用种子都有机会露出。
|
||||
chosen = _rng.sample(seeds, need) if len(seeds) > need else list(seeds)
|
||||
_rng.shuffle(chosen)
|
||||
for s in chosen:
|
||||
# 用户名:旧的「用户****xxx」统一模板名 / 留空 → 一律走新混合合成(避开同屏撞名、自愈历史种子);
|
||||
# 仅运营手动设的非模板真名才原样用。
|
||||
fixed = (s.masked_user or "").strip()
|
||||
name = fixed if fixed and not fixed.startswith("用户****") else _unique_name(used_names)
|
||||
used_names.add(name)
|
||||
# 金额:在 [min,max] 取长尾随机值(小额居多、偶尔大额;固定金额则 min==max)。
|
||||
lo = max(0, int(s.min_cents))
|
||||
hi = max(lo, int(s.max_cents))
|
||||
items.append({"masked_user": name, "saved_amount_cents": _skewed_amount(lo, hi)})
|
||||
|
||||
# 兜底:真实 + 种子仍凑不满 limit(种子被全停用 / 数量太少)→ 用内置合成补满,
|
||||
# 保证轮播既不空也不稀疏(稀疏的几条循环同样像假)。
|
||||
while len(items) < limit:
|
||||
name = _unique_name(used_names)
|
||||
used_names.add(name)
|
||||
# 金额:在 [min,max] 取长尾随机值(小额居多、偶尔大额;固定金额则 min==max)。
|
||||
lo = max(0, int(s.min_cents))
|
||||
hi = max(lo, int(s.max_cents))
|
||||
items.append({"masked_user": name, "saved_amount_cents": _skewed_amount(lo, hi)})
|
||||
|
||||
# 兜底:真实 + 种子仍凑不满 limit(种子被全停用 / 数量太少)→ 用内置合成补满,
|
||||
# 保证轮播既不空也不稀疏(稀疏的几条循环同样像假)。
|
||||
while len(items) < limit:
|
||||
name = _unique_name(used_names)
|
||||
used_names.add(name)
|
||||
items.append({
|
||||
"masked_user": name,
|
||||
"saved_amount_cents": _skewed_amount(_FALLBACK_MIN_CENTS, _FALLBACK_MAX_CENTS),
|
||||
})
|
||||
items.append({
|
||||
"masked_user": name,
|
||||
"saved_amount_cents": _skewed_amount(_FALLBACK_MIN_CENTS, _FALLBACK_MAX_CENTS),
|
||||
})
|
||||
|
||||
# 统一赋「最近」时间:从 now 往前**随机抖动**递减,避免固定节奏被看出规律。
|
||||
# 首条几十秒前;其余多数 1~5 分钟,偶尔扎堆(20~55s)或较长(5~9 分钟),降序、像真实流水。
|
||||
@@ -276,6 +310,78 @@ def get_feed(db: Session, limit: int = 8) -> list[dict]:
|
||||
return items
|
||||
|
||||
|
||||
# ===== admin 侧:分页浏览「当前模式下可展示的记录」(审核用,不去重) =====
|
||||
_REAL_BROWSE_CAP = 1000 # 真实记录一次最多纳入这么多去洗牌+分页(足够审核;防超大库全量洗牌)
|
||||
_BROWSE_SEED = 20260707 # 固定洗牌种子:同一批数据下排列恒定 → 翻页稳定、能翻遍全部
|
||||
|
||||
|
||||
def _seed_browse_row(seed: OpsMarqueeSeed) -> dict:
|
||||
"""把一条种子按其「生成逻辑」**确定性**生成一行浏览项(名字/金额按 seed.id 派生固定种子 → 翻页稳定)。
|
||||
名字:运营手填的非模板名原样用,否则本地合成;金额:区间内确定性长尾取值。user_id=0(种子无真实用户)。"""
|
||||
r = random.Random(_BROWSE_SEED * 1_000_003 + int(seed.id))
|
||||
fixed = (seed.masked_user or "").strip()
|
||||
name = fixed if (fixed and not fixed.startswith("用户****")) else _synth_name(r)
|
||||
lo = max(0, int(seed.min_cents))
|
||||
hi = max(lo, int(seed.max_cents))
|
||||
amt = lo if hi <= lo else lo + int(round((hi - lo) * (r.random() ** 2.2)))
|
||||
return {"masked_user": name, "saved_amount_cents": amt, "created_at": "", "user_id": 0}
|
||||
|
||||
|
||||
def list_real_records(
|
||||
db: Session, mode: str | None = None, offset: int = 0, limit: int = 8
|
||||
) -> tuple[list[dict], int]:
|
||||
"""分页浏览「**当前模式**下可在 app 轮播展示的全部记录」,供 admin 逐页审核(**不去重**):
|
||||
- 只真实:全部 success+省>0 的真实记录;
|
||||
- 只种子:每条启用种子按生成逻辑各出一行;
|
||||
- 混播:真实 + 种子 全部合在一起。
|
||||
与 app 轮播同口径:先洗牌 + 去连簇(相邻尽量不同 user;种子各自独立、不算同 user);**固定种子** →
|
||||
同批数据下排列恒定,翻页不跳、能翻遍全部。返回 (items, total);item.user_id=0 表示种子。"""
|
||||
mode = mode if mode in FEED_MODES else get_feed_mode(db)
|
||||
# pool: [(cluster_key, item)];cluster_key 供去连簇——真实=user_id、种子=各自唯一负数(互不聚簇)
|
||||
pool: list[tuple[int, dict]] = []
|
||||
if mode != "seed":
|
||||
rows = db.execute(
|
||||
select(
|
||||
ComparisonRecord.user_id,
|
||||
ComparisonRecord.saved_amount_cents,
|
||||
User.nickname,
|
||||
ComparisonRecord.created_at,
|
||||
)
|
||||
.join(User, User.id == ComparisonRecord.user_id)
|
||||
.where(
|
||||
ComparisonRecord.status == "success",
|
||||
ComparisonRecord.saved_amount_cents > 0,
|
||||
ComparisonRecord.saved_amount_cents <= _REAL_MAX_CENTS,
|
||||
)
|
||||
.order_by(ComparisonRecord.created_at.desc())
|
||||
.limit(_REAL_BROWSE_CAP)
|
||||
).all()
|
||||
for uid, sc, nick, ca in rows:
|
||||
pool.append((
|
||||
int(uid),
|
||||
{
|
||||
"masked_user": _mask_real(nick, int(uid)),
|
||||
"saved_amount_cents": int(sc),
|
||||
"created_at": str(ca)[:16] if ca is not None else "",
|
||||
"user_id": int(uid),
|
||||
},
|
||||
))
|
||||
if mode != "real":
|
||||
seeds = (
|
||||
db.execute(select(OpsMarqueeSeed).where(OpsMarqueeSeed.enabled.is_(True)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for i, s in enumerate(seeds):
|
||||
pool.append((-(i + 1), _seed_browse_row(s))) # 每个种子唯一 key → 互不聚簇
|
||||
total = len(pool)
|
||||
# 每次用同一固定种子新建 Random → 同批数据排列恒定(翻页稳定);同时相邻尽量不同 user。
|
||||
ordered = _shuffle_declustered(pool, random.Random(_BROWSE_SEED))
|
||||
off = max(0, offset)
|
||||
items = [item for _key, item in ordered[off : off + limit]]
|
||||
return items, total
|
||||
|
||||
|
||||
# ===== 运营侧:种子 CRUD =====
|
||||
def list_seeds(db: Session) -> list[OpsMarqueeSeed]:
|
||||
return list(
|
||||
|
||||
@@ -318,11 +318,14 @@ def update_config(
|
||||
row.random_current = _monotonic(row, random_initial)
|
||||
row.random_last_tick_at = now
|
||||
elif apply_now:
|
||||
# 立即更新:不等钟点,马上刷新一次
|
||||
# 立即更新:不等钟点,马上刷新一次。
|
||||
# random:走一档增长;real/manual:直接落到「配置目标值」(manual_value / max(真实,保底)),
|
||||
# **显式保存即所见即所得,绕过「只增不减」护栏**——运营手动改值就是要按配置显示(含调小)。
|
||||
# (护栏仍作用于自动 tick 的 _refresh:防门面在两次保存之间被真实刷新/自增长悄悄缩水。)
|
||||
if row.mode == "random" and row.random_current is not None:
|
||||
row.random_current = _grow(row, row.random_current, 1)
|
||||
else:
|
||||
row.random_current = _monotonic(row, _current_target(db, row))
|
||||
row.random_current = _current_target(db, row)
|
||||
row.random_last_tick_at = now
|
||||
elif row.random_current is None:
|
||||
# 首次无值:按当前模式播种,使配置后立即有合理展示值
|
||||
|
||||
@@ -42,6 +42,22 @@ def _gen_nickname() -> str:
|
||||
)
|
||||
|
||||
|
||||
def is_default_nickname(nickname: str | None) -> bool:
|
||||
"""是否为创建时自动分配的默认昵称(= "用户" + 9 位字母数字,见 [_gen_nickname])。
|
||||
|
||||
这类不是用户主动设置的昵称,展示脱敏时按「无昵称」处理(走 id 规则,见 ops_marquee._mask_real)。
|
||||
精确匹配生成格式(前缀 + 定长字母数字集),不误伤真人以「用户」开头的昵称(如「用户体验师」含汉字、
|
||||
长度也不符)。用户改过昵称即不再匹配。"""
|
||||
if not nickname:
|
||||
return False
|
||||
s = nickname.strip()
|
||||
return (
|
||||
len(s) == len(_NICKNAME_PREFIX) + _NICKNAME_LEN
|
||||
and s.startswith(_NICKNAME_PREFIX)
|
||||
and all(c in _NICKNAME_ALPHABET for c in s[len(_NICKNAME_PREFIX):])
|
||||
)
|
||||
|
||||
|
||||
def get_user_by_username(db: Session, username: str) -> User | None:
|
||||
return db.execute(
|
||||
select(User).where(User.username == username)
|
||||
|
||||
+101
-1
@@ -11,7 +11,7 @@ import unicodedata
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -67,6 +67,10 @@ class WithdrawTooFrequentError(Exception):
|
||||
"""提现申请过于频繁,或已有未完成提现单。"""
|
||||
|
||||
|
||||
class WithdrawTierUnavailableError(Exception):
|
||||
"""该档位今日不可提:次数已满,或今天已选了其他额度(7-9 福利页档位规则)。"""
|
||||
|
||||
|
||||
class WithdrawTransferError(Exception):
|
||||
"""调用微信转账失败(已退回余额)。"""
|
||||
|
||||
@@ -605,6 +609,89 @@ def _settle_after_ambiguous(db: Session, order: WithdrawOrder, reason: str) -> N
|
||||
db.commit()
|
||||
|
||||
|
||||
def _beijing_today_start_utc() -> datetime:
|
||||
"""北京时今日 0 点(转 UTC)。WithdrawOrder.created_at 是 func.now()(UTC)存储,
|
||||
比较时统一转 UTC,与 admin 看板 today_start 同口径(admin/repositories/queries.py)。"""
|
||||
return (
|
||||
datetime.now(rewards.CN_TZ)
|
||||
.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
.astimezone(timezone.utc)
|
||||
)
|
||||
|
||||
|
||||
def withdraw_tier_states(db: Session, user_id: int, source: str = "coin_cash") -> list[dict]:
|
||||
"""福利页(coin_cash)提现档位的可提现状态。withdraw-info 下发与 create_withdraw 校验共用此口径。
|
||||
|
||||
规则(2026-07-09 拍板,7-9提现ui对齐):
|
||||
- 新人档(0.1/0.3):账号历史一次性——只要发起过(**任意状态**,含被拒/失败,"发起就算")
|
||||
即视为已用,直接**从返回列表消失**;两档各自独立互不影响,不参与"每日选一个额度"互斥。
|
||||
- 常规档(0.5×3 / 10×1 / 20×1):按北京日计次,"发起就算占用"(当天创建的单不论最终状态
|
||||
都计入,被拒/失败不退当天名额);三档每天只能选一个,选定后其余两档当天 other_tier_selected。
|
||||
- invite_cash 本轮无档位概念 → 返回空列表(邀请页客户端仍用本地写死档位,行为不变)。
|
||||
余额是否足够由客户端本地判断(余额随兑换实时变化,不在此快照)。
|
||||
"""
|
||||
if source != "coin_cash":
|
||||
return []
|
||||
tiers = rewards.WITHDRAW_TIERS_COIN_CASH
|
||||
amounts = [t.amount_cents for t in tiers]
|
||||
newbie_amounts = [t.amount_cents for t in tiers if t.is_newbie]
|
||||
# 新人档历史是否用过:任意时间、任意状态("发起就算")
|
||||
used_newbie: set[int] = set(
|
||||
db.execute(
|
||||
select(WithdrawOrder.amount_cents)
|
||||
.distinct()
|
||||
.where(
|
||||
WithdrawOrder.user_id == user_id,
|
||||
WithdrawOrder.source == "coin_cash",
|
||||
WithdrawOrder.amount_cents.in_(newbie_amounts),
|
||||
)
|
||||
).scalars()
|
||||
) if newbie_amounts else set()
|
||||
# 今日(北京日)每档已发起次数(任意状态)
|
||||
today_counts: dict[int, int] = {
|
||||
int(amount): int(cnt)
|
||||
for amount, cnt in db.execute(
|
||||
select(WithdrawOrder.amount_cents, func.count(WithdrawOrder.id))
|
||||
.where(
|
||||
WithdrawOrder.user_id == user_id,
|
||||
WithdrawOrder.source == "coin_cash",
|
||||
WithdrawOrder.amount_cents.in_(amounts),
|
||||
WithdrawOrder.created_at >= _beijing_today_start_utc(),
|
||||
)
|
||||
.group_by(WithdrawOrder.amount_cents)
|
||||
)
|
||||
}
|
||||
# "每日选一个额度":今天发起过的常规档(新人档不算)
|
||||
selected_regular = next(
|
||||
(t.amount_cents for t in tiers if not t.is_newbie and today_counts.get(t.amount_cents, 0) > 0),
|
||||
None,
|
||||
)
|
||||
out: list[dict] = []
|
||||
for t in tiers:
|
||||
if t.is_newbie:
|
||||
if t.amount_cents in used_newbie:
|
||||
continue # 用过即消失,不再下发
|
||||
out.append({
|
||||
"amount_cents": t.amount_cents, "label": t.label, "badge": t.badge,
|
||||
"is_newbie": True, "available": True, "disabled_reason": None,
|
||||
"remaining_today": 1,
|
||||
})
|
||||
continue
|
||||
used = today_counts.get(t.amount_cents, 0)
|
||||
if selected_regular is not None and selected_regular != t.amount_cents:
|
||||
available, reason, remaining = False, "other_tier_selected", 0
|
||||
elif used >= t.daily_limit:
|
||||
available, reason, remaining = False, "quota_exhausted", 0
|
||||
else:
|
||||
available, reason, remaining = True, None, t.daily_limit - used
|
||||
out.append({
|
||||
"amount_cents": t.amount_cents, "label": t.label, "badge": t.badge,
|
||||
"is_newbie": False, "available": available, "disabled_reason": reason,
|
||||
"remaining_today": remaining,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def create_withdraw(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
@@ -660,6 +747,19 @@ def create_withdraw(
|
||||
if active_order_id is not None:
|
||||
raise WithdrawTooFrequentError
|
||||
|
||||
# 福利页档位闸(7-9):coin_cash 只能提预设档位,且该档今日可提(服务端权威口径,防绕过
|
||||
# 客户端刷)。放在幂等返回/在途互斥之后:同号重试仍原样返回旧单,不被档位闸误杀。
|
||||
# allow_sub_min(0.01 调试直发)保持原样放行,不受档位约束;invite_cash 本轮无档位概念不校验。
|
||||
if source == "coin_cash" and not allow_sub_min:
|
||||
tier_state = next(
|
||||
(t for t in withdraw_tier_states(db, user_id, source) if t["amount_cents"] == amount_cents),
|
||||
None,
|
||||
)
|
||||
if tier_state is None: # 非预设档位金额,或新人档已用过(已从列表消失)
|
||||
raise InvalidWithdrawAmountError
|
||||
if not tier_state["available"]:
|
||||
raise WithdrawTierUnavailableError
|
||||
|
||||
# 账户须存在(原子扣款的 UPDATE 不会建账户)
|
||||
get_or_create_account(db, user_id, commit=True)
|
||||
|
||||
|
||||
@@ -63,6 +63,12 @@ class EcpmReportIn(BaseModel):
|
||||
description="点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页);"
|
||||
"比价与领券共用同一 Draw 代码位,需客户端在各调用点显式标注,供收益报表区分比价/领券;激励视频为空",
|
||||
)
|
||||
trace_id: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="本次比价/领券 trace_id(信息流场景带上):把这条展示收益归属到对应比价/领券,"
|
||||
"供领券数据/比价记录看板聚合本场广告收益;激励视频/福利为空",
|
||||
)
|
||||
app_env: str | None = Field(
|
||||
None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""自报计数上报 schema。字段名对齐客户端 payload(snake_case),累计值语义。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SelfStatEventIn(BaseModel):
|
||||
event: str = Field(max_length=64)
|
||||
attempted: int = 0
|
||||
drop_capture: int = 0
|
||||
delivered: int = 0
|
||||
drop_undelivered: int = 0
|
||||
|
||||
|
||||
class SelfStatBatchIn(BaseModel):
|
||||
device_id: str = Field(max_length=64)
|
||||
epoch_id: str = Field(max_length=64)
|
||||
sent_at: int | None = None
|
||||
app_ver: str | None = Field(default=None, max_length=32)
|
||||
oem: str | None = Field(default=None, max_length=32)
|
||||
os: str | None = Field(default=None, max_length=32)
|
||||
batches_attempted: int = 0
|
||||
batches_ok: int = 0
|
||||
batches_fail: int = 0
|
||||
retries: int = 0
|
||||
queue_depth: int = 0
|
||||
# 允许空列表:某次快照只上报设备级计数(batches_*/retries/queue_depth)、无 event 细分时也合法
|
||||
# (与 AnalyticsBatchIn 的 min_length=1 有意不同——那是行为事件、必须至少一条)。
|
||||
events: list[SelfStatEventIn] = Field(default_factory=list, max_length=200)
|
||||
|
||||
|
||||
class SelfStatIngestOut(BaseModel):
|
||||
ok: bool = True
|
||||
snapshot_id: int
|
||||
@@ -174,9 +174,13 @@ class FeedResponse(BaseModel):
|
||||
|
||||
class TopSalesRequest(BaseModel):
|
||||
"""销量最高 tab:从离线库 meituan_coupon 按销量降序取(不实时打美团)。"""
|
||||
# 可选:老客户端(本次改动前发版)不带经纬度。缺省时后端降级返空(status=degraded),
|
||||
# 不做 422 硬拒,也不误返"全城"结果。新客户端会传坐标 → 按城市过滤。
|
||||
longitude: float | None = Field(None, description="经度(用于定位城市;缺省=老客户端,降级返空)")
|
||||
latitude: float | None = Field(None, description="纬度(用于定位城市;缺省=老客户端,降级返空)")
|
||||
page: int = Field(1, ge=1)
|
||||
page_size: int = Field(20, ge=1, le=50)
|
||||
platform: int | None = Field(None, description="可选: 1只外卖 / 2只到店; 不填=全部(全城销量)")
|
||||
platform: int | None = Field(None, description="可选: 1只外卖 / 2只到店; 不填=全部(同城销量)")
|
||||
|
||||
|
||||
# ───────────────── 换链 请求 / 响应 ─────────────────
|
||||
|
||||
@@ -75,6 +75,21 @@ class ExchangeResultOut(BaseModel):
|
||||
|
||||
# ===== 提现(现金 → 微信零钱) =====
|
||||
|
||||
class WithdrawTierOut(BaseModel):
|
||||
"""提现档位(福利页 coin_cash;7-9 对齐原型)。served by rewards.WITHDRAW_TIERS_COIN_CASH。"""
|
||||
|
||||
amount_cents: int = Field(..., description="档位金额(分)")
|
||||
label: str = Field(..., description="档位方块展示文案,如 0.1 / 10")
|
||||
badge: str | None = Field(None, description="角标文案(如 新人福利);无则空")
|
||||
is_newbie: bool = Field(False, description="新人档:历史一次性,用过后不再下发;免广告直提")
|
||||
available: bool = Field(True, description="当前是否可提(次数/选一额度口径;余额由客户端自判)")
|
||||
disabled_reason: str | None = Field(
|
||||
None,
|
||||
description="不可提原因:quota_exhausted(今日次数满) / other_tier_selected(今日已选其他额度)",
|
||||
)
|
||||
remaining_today: int = Field(0, description="今日剩余可提次数")
|
||||
|
||||
|
||||
class WithdrawInfoOut(BaseModel):
|
||||
min_cents: int = Field(..., description="单次最低提现(分)")
|
||||
max_cents: int = Field(..., description="单次最高提现(分)")
|
||||
@@ -84,6 +99,10 @@ class WithdrawInfoOut(BaseModel):
|
||||
transfer_auth_enabled: bool = Field(
|
||||
False, description="是否已开启免确认到账(开启后提现免跳微信确认,直接到账)"
|
||||
)
|
||||
tiers: list[WithdrawTierOut] = Field(
|
||||
default_factory=list,
|
||||
description="提现档位(source=coin_cash 下发;invite_cash 为空,客户端走旧逻辑)",
|
||||
)
|
||||
|
||||
|
||||
# ===== 免确认收款授权(用户授权免确认模式)=====
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
城市ID 城市名称 省份名称
|
||||
3NUYJKKJXPHVNZUHFK3HWUDHNM 宣城市 安徽省
|
||||
LXXSHOY7LNK74ZK2SKVUXFY72Q 阜阳市 安徽省
|
||||
ZEBF2LBJOEHGM4XGFPNW4IHBIA 合肥市 安徽省
|
||||
WADWEY3GR6IARJLSNGQWG2KI4E 滁州市 安徽省
|
||||
Z62QL3X66AOT6MSY7LB6LVO4CI 芜湖市 安徽省
|
||||
M6QCWRCECRT6ZEJ6RERR6IWHFI 淮南市 安徽省
|
||||
UP2NBJACSAO7FUH4XQQDMBOUE4 马鞍山市 安徽省
|
||||
WLL7BSHUBPTLTMITFITJX2GKWY 蚌埠市 安徽省
|
||||
ECTBNJ4KNNHPGXEVI4TBJNU7BY 亳州市 安徽省
|
||||
KNEGDNOSRGNQPAMOMH54ATLIUU 六安市 安徽省
|
||||
YH53FRR55H6VA4KXW36OJ7RNSE 宿州市 安徽省
|
||||
LH6LX5DUVFPOFCAQ6NEGPWCLBI 淮北市 安徽省
|
||||
RIFXWJ46SEJXE7EP6HZXEUIALU 铜陵市 安徽省
|
||||
FK6F7KMO4WNARZKUXMMSBWGATM 安庆市 安徽省
|
||||
M3CHGNNUSFSPS5HRV3W7MCBWFI 黄山市 安徽省
|
||||
XFI6PM7SRO6NHBGYQUZEEA2WT4 池州市 安徽省
|
||||
D2ZILSASTTGEFQWZIDTHA7PWU4 澳门 澳门特别行政区
|
||||
WKV2HMXUEK634WP64CUCUQGM64 北京市 北京市
|
||||
HPMKHLM3QR6EZGMY7GEI4H3QYQ 泉州市 福建省
|
||||
6V523YRU54Y3PEAM2XPADNJM2U 福州市 福建省
|
||||
HH3ZZCERPVQIYUZPW4A2U4JKZI 莆田市 福建省
|
||||
BQ5RWEJS4O7W27SQMLPMRIRJDU 宁德市 福建省
|
||||
TDN7XDQMZEP6ZCK6UO3VVMCSMM 三明市 福建省
|
||||
ZKNTORN6YTZL2BXRYUSRGV3CHU 厦门市 福建省
|
||||
Y2DI2QLZN5NNACMD3KI2DBR4IA 龙岩市 福建省
|
||||
C4RS32I6QAHWLP55UQWI3N5LLY 南平市 福建省
|
||||
5T23WDEOAP7RYNU4JNL2ZG7PDQ 漳州市 福建省
|
||||
UHZBROATFB2KWNMNLS23DPRJBY 定西市 甘肃省
|
||||
WLPAHIUOIVKS644QSN4V5ZY5XQ 金昌市 甘肃省
|
||||
J7TO3UHZ57ABNUKIQUBCIJZQFA 白银市 甘肃省
|
||||
GX277SS75375VEFYVCEHBL6ISA 临夏回族自治州 甘肃省
|
||||
ORA3R7F2LSJHUOCIDTZCP54G7Q 张掖市 甘肃省
|
||||
L6VQYYOJNTHSLW5JCR5SXBITDM 武威市 甘肃省
|
||||
65OXQPQVFXNOYHXTMCE2RNFRL4 兰州市 甘肃省
|
||||
IUIZYQ7E2SPMEAIGOUUNZWBQOA 天水市 甘肃省
|
||||
BYBRGRDRV4NKAWCU6GWBIVLX3Q 酒泉市 甘肃省
|
||||
KDST2VRETG6WK5SMJO2G2FN2RU 嘉峪关市 甘肃省
|
||||
R3Q2XWVFVF4T2ADZZZMPZJRWBA 庆阳市 甘肃省
|
||||
7FWNT2TP66SU4QEP6IBBMZWFNE 陇南市 甘肃省
|
||||
T33S2GYGPVHAL5SR2FLSPTJSBE 平凉市 甘肃省
|
||||
RR6KAWBLOKD4H2UINKYPFCPXO4 甘南藏族自治州 甘肃省
|
||||
QKX4DS3CTJJG7SFW5SBHPLD42I 茂名市 广东省
|
||||
JJZ75A32XCQNZU4IN2ZCEUGN3M 梅州市 广东省
|
||||
SJSOXOSJASLUT6LBH4E32SUKKQ 清远市 广东省
|
||||
SQQWAN5BQOVSX55S7EPF7QHMAU 珠海市 广东省
|
||||
NGRJMW6JMRS6U2KUJEONSORAEY 韶关市 广东省
|
||||
FAUMIGOSOET4E5WR5BL6P3OZHA 佛山市 广东省
|
||||
JSBIH55ICFZQ2D3LEV47YMZ2NI 河源市 广东省
|
||||
KOYAYPD2DBLDCF2ZI5GCW4LF6Y 中山市 广东省
|
||||
647JGFPUYM4VWVLZCPSHT63XKQ 汕头市 广东省
|
||||
AMOIPZW3Q2NMTDSEREFVM4SV74 深圳市 广东省
|
||||
XIHEJY4H2CDZJCLIXDIN36BKXQ 广州市 广东省
|
||||
UZ6OT4CYUR42KCTTED2KJW6EGA 东莞市 广东省
|
||||
RM3HLOIUEYKTQ5O2JSVEKDMFRY 阳江市 广东省
|
||||
C6XNJAZA6N3NNUDBUU3JIZPTDI 潮州市 广东省
|
||||
AUPF3G2ULSV4TDT4L3NMHRTY6Y 揭阳市 广东省
|
||||
7HIITKBPRXTVBA2FBBN443XITQ 云浮市 广东省
|
||||
TO6ILZ7MPJMJN3S7W2SXMIFQQY 江门市 广东省
|
||||
DTTBMGCIOMPCZY5NETUEKWJ6PY 汕尾市 广东省
|
||||
HJY7JYWBA6FQY42RYSKX3RZTRI 湛江市 广东省
|
||||
SLICHB4FBDVDLI53MR74WVUUNI 肇庆市 广东省
|
||||
ZPX4JXJVBBYSSD2KTWHAPXO6NE 惠州市 广东省
|
||||
JH4Q44RQA4EZ3Q6MHQVEVE7KZQ 百色市 广西壮族自治区
|
||||
H2JXFEJFIL4PPFMYOS4MHZ5IBM 崇左市 广西壮族自治区
|
||||
SXIRRISUOEGBU335AWT2ZFL6A4 贵港市 广西壮族自治区
|
||||
HEQHKC4KP7YGGYVBZM5JEUI5AQ 北海市 广西壮族自治区
|
||||
SKGG7KMFKVDIDKRVQEPTS7SIE4 贺州市 广西壮族自治区
|
||||
N4WR7CWCULNA5Z35OTDJSZYDCU 钦州市 广西壮族自治区
|
||||
T4RXX2WY6WPQYZEUXVJNZBXQZU 梧州市 广西壮族自治区
|
||||
3R23AS3EIY7EYE2D5MWWORZODI 河池市 广西壮族自治区
|
||||
57SMWWCV7X44E256P4I23OQ3AA 防城港市 广西壮族自治区
|
||||
YHGHVIQ37UCTNQ4JKPQEAUWIQA 桂林市 广西壮族自治区
|
||||
MQJZTM455OKZLAN5WQYUTA5TDE 柳州市 广西壮族自治区
|
||||
C6FZPLB4NJQ6VUPSKDJH3EWQDM 玉林市 广西壮族自治区
|
||||
BFSU5W6E5XBIDFPQLVPGRDSATY 南宁市 广西壮族自治区
|
||||
CKXOQUZDNOVNME3PEBOY2CULQQ 来宾市 广西壮族自治区
|
||||
LV32FV6IQTKFR7JIBEQMHRUVCA 贵阳市 贵州省
|
||||
F26RCNKMFTZONJCSJ5C6FHVY74 毕节市 贵州省
|
||||
G2LMYRWVRCK7BTD2WM4NWX4SYM 黔南布依族苗族自治州 贵州省
|
||||
MFSOO3NBMB2PVLIVSI5EJK7MWY 黔西南布依族苗族自治州 贵州省
|
||||
KWUL44L7SEMJGIMXCWSSEB3OOA 遵义市 贵州省
|
||||
T2P3OFGQZUGR7D6TGRCMDF22GI 铜仁市 贵州省
|
||||
AGUFUANSZNGC4TMOPZO65IRSPI 六盘水市 贵州省
|
||||
2XOCOSNUAK3J5QDGTKIBWKK7KU 安顺市 贵州省
|
||||
6XRTSAEYJTA2UBKXO4XEPQE5ZY 黔东南苗族侗族自治州 贵州省
|
||||
YRMKRP2GOE2VMRS73N4YRIZUHY 三亚市 海南省
|
||||
CJRGVLBNLJAVBJ4ZKKIZ3FZ2LY 白沙黎族自治县 海南省
|
||||
5XOUAJ5Z4J4K7SVIQGXL2OM2JQ 保亭黎族苗族自治县 海南省
|
||||
2UFQ6A2QRJPXPH3VOYEAQHMVSQ 海口市 海南省
|
||||
TGCVXVS4M7NDQM4ROUDCVI6I3A 承德市 河北省
|
||||
JEUP6QWCOXPSM3SQTINQCJKIGM 衡水市 河北省
|
||||
Z442MNCW6BO2BBHIRUPRBACXPI 唐山市 河北省
|
||||
RFE6R34GD4FY3LUKC2ICSF6AFY 张家口市 河北省
|
||||
CWJN55M73VZDCYJEQ7AHDBWGGY 沧州市 河北省
|
||||
DFL4ES776ECRGBYNOPLWKB247I 雄安新区 河北省
|
||||
ZLSXYY34IHBHIC2NOVPQQBFTBE 保定市 河北省
|
||||
3DO6Z2QRJQFMPLLDS55PG7DSBU 石家庄市 河北省
|
||||
PR57XT25LI3246VGASEPSHP63E 邢台市 河北省
|
||||
SKYLNH737BS56TD452FOKYL36U 邯郸市 河北省
|
||||
5PWPERL7GQKJD6QPLR2TWUUM7E 秦皇岛市 河北省
|
||||
5T2TGV6SJFVL3MO7HIMN2KTQTA 廊坊市 河北省
|
||||
ECSTLZ7GP7IX3MB5EVNKS47MLE 焦作市 河南省
|
||||
VTWW34QB2F5Q4LW7ISNUMWX7GY 开封市 河南省
|
||||
D2NUN47NY4Q55X3UED4JMSI6CM 周口市 河南省
|
||||
TR3XJFQR4EFYRRIX7TUQF3B26Y 郑州市 河南省
|
||||
CKJGF5S6XMHW5ZJEBU7MJC47QA 新乡市 河南省
|
||||
IFASZ625MCFJQKPLJ7EA2SMJUU 商丘市 河南省
|
||||
SKXPYKTTRG4YAUHE2HZXWRWXGM 鹤壁市 河南省
|
||||
G5LXE74CUHO2K6BBRN7Q5DSRJY 漯河市 河南省
|
||||
SLNOAFJV2LTBSH7SJCRXJA36K4 驻马店市 河南省
|
||||
65WO7LH7CFDUGKYMXQLRAYKKWM 安阳市 河南省
|
||||
RIX2X7FAVTZCAQ5RT2C2CWK22Q 南阳市 河南省
|
||||
SZOW5OY3U54SSY4WRC65VJUNTI 平顶山市 河南省
|
||||
7VPIDDUS4P2LSZ6Q5S57MAQDEM 信阳市 河南省
|
||||
4VYCRORUOZ4DC2U6S3CT6H6KWE 洛阳市 河南省
|
||||
M5WNO2BQ3UGLLHBCG4NCEFPP5U 濮阳市 河南省
|
||||
LY3O6PBPIWETMA3ZOL6ETY5UB4 三门峡市 河南省
|
||||
FXXJLIRE72LS2W4OWWQVJMRJHA 许昌市 河南省
|
||||
5XH353QTY3VWF2KYOCZCL3TOXY 牡丹江市 黑龙江省
|
||||
2KGRZKF6IECV2W7K5J64Y2LY4M 齐齐哈尔市 黑龙江省
|
||||
FASGWS5ADVSTFGJG6TGBZPZP6Q 鹤岗市 黑龙江省
|
||||
2O6CDIXSWIKBXILZEEPKCS7MVI 双鸭山市 黑龙江省
|
||||
OZ2PTOBYTBG57XJZMIC23QFJKM 佳木斯市 黑龙江省
|
||||
FO24MQMULT3J5JW64APNXSQEPU 伊春市 黑龙江省
|
||||
ETZ2HYWVU6U7SKU6G4JAO64RUQ 黑河市 黑龙江省
|
||||
PR7EJNBY2VZBEUT36JAWE3TM7I 七台河市 黑龙江省
|
||||
HADAAVLERKIW4SQGCTQYGX4AL4 哈尔滨市 黑龙江省
|
||||
CGTU45YC5C3JYLHMA47USDPA7Y 大庆市 黑龙江省
|
||||
T4W7SQIPOM4EYMEFFRAB5BSTII 鸡西市 黑龙江省
|
||||
TYGZHNQL6YT7CX6EEG5DJQQHMA 绥化市 黑龙江省
|
||||
OOSJTSN2CVUUCKD6XAB7EYYIPY 大兴安岭地区 黑龙江省
|
||||
I3YF3EKZHIZTN6TZOTYTGZ2UXQ 随州市 湖北省
|
||||
ESGVBOSTHW7JWEVCGYJUTEHEBQ 宜昌市 湖北省
|
||||
MTJRWJ53XBW5SBTWHKNNZDLM7U 十堰市 湖北省
|
||||
PXZLF2ISKQL5ACM67ZCBNOGDT4 黄石市 湖北省
|
||||
44RMTOEHPUFXBHZXX4IQ4IRZVQ 荆州市 湖北省
|
||||
OTKZGG743NFC474ADMMRX4ZOOA 鄂州市 湖北省
|
||||
EXOUAZAQ73OEFAK72CHQ32GQHQ 恩施土家族苗族自治州 湖北省
|
||||
SUCY7I72QJDZD7EBFXREIQ67SI 咸宁市 湖北省
|
||||
QENSGB5R7HGYDXCG2LQZQTO3TU 荆门市 湖北省
|
||||
OHIWL6SAE2PR4EJR4BOMLAE6FU 武汉市 湖北省
|
||||
ZXCE4WV2CDVPQTA4HAOVELQMNE 襄阳市 湖北省
|
||||
KEFN5OPSS4ZZF6NU2TTL72S6HE 孝感市 湖北省
|
||||
ROAHLMQ67H6M5NDFVXROJG723E 黄冈市 湖北省
|
||||
YBEBX2YYN4WPBNH6Z6C73DNE7I 张家界市 湖南省
|
||||
45XGRKYGSCPE5VNRYF4FVJGFMM 株洲市 湖南省
|
||||
LK3SEIBRU7GTDT4J2EPTLIO33U 永州市 湖南省
|
||||
R4YXFIK53W5E556BSGSBJWS4DM 郴州市 湖南省
|
||||
PQDO3RNADWXX75OWZW2GSXJ4SE 怀化市 湖南省
|
||||
RRRT6QOJYEJ432L3F76ZN5NHCA 长沙市 湖南省
|
||||
B6WPNMCZ3ENQSV4NFY5MSTPDAM 岳阳市 湖南省
|
||||
KNDZW5EHDPKP2DX7HBLKP4DYLM 益阳市 湖南省
|
||||
PA2GHG3XZ7I47HTKZ4YAFH3OYY 湘西土家族苗族自治州 湖南省
|
||||
I7CNIUA5PYV2EHDEW3RGYT2R4U 邵阳市 湖南省
|
||||
SRI2SU4FN66FMJJCKQOCZD72ZY 常德市 湖南省
|
||||
H7UHHJAMQUL7UA5QEUTGKNSL3A 湘潭市 湖南省
|
||||
EFB255OBTB2BUDZENR5UVIC7ZQ 衡阳市 湖南省
|
||||
RDMANB4KCM3OJSNVGZWVYVME6E 娄底市 湖南省
|
||||
LD37PDU5OB4UAV5QDOBMKG5YTY 吉林市 吉林省
|
||||
EO3GF4XNF5RXWRPUVAT3KTQO4U 四平市 吉林省
|
||||
4GD7OS4CAQABH5YIWVK5SKGHMY 通化市 吉林省
|
||||
6DRI2R5VAWMYJHJPCJKUNMDYEQ 延边朝鲜族自治州 吉林省
|
||||
TVBCNVGUND4MOUOOUXFGX7DIUA 松原市 吉林省
|
||||
JYY62HSKBUVK5OU7KGJDKQ4RTA 白城市 吉林省
|
||||
QEDUHKMZ36CHJTKRD6O2ZPLNBU 长春市 吉林省
|
||||
4EADVCBJMZ5UBH2FVRT6QCLS2U 辽源市 吉林省
|
||||
EUQD5EGS2LR5KJSFNG6PPSIHHI 白山市 吉林省
|
||||
YLTIISPCLBEGTZZX3WUWAD7WDE 淮安市 江苏省
|
||||
L6U5DZP6MESXPMHOHCDMJS55O4 宿迁市 江苏省
|
||||
HQMLYA7TDGMYQAXFCDUXBZPYHI 镇江市 江苏省
|
||||
K6XJ4UN65ZD6XQKYEG5YN7HCRI 盐城市 江苏省
|
||||
36I4X3EZZU4EHOSCLQI5OAKKBE 南通市 江苏省
|
||||
S3GWFQU6QAVRDKLJT77LD6OFLE 泰州市 江苏省
|
||||
IO6F4AFGAVIFRGYTZEC4TXM7W4 无锡市 江苏省
|
||||
NUXNK2VOFSD2JFTEO2AMWX6NSU 扬州市 江苏省
|
||||
TEVZU6CU6SK57HFW7DFNGMQ44A 南京市 江苏省
|
||||
UTYSRBQ4FSB7XLWCF3Z2HTKNUA 常州市 江苏省
|
||||
OCZOBCJDEXKE7KBN3BD7AYQG2Q 徐州市 江苏省
|
||||
6LIBPJGZROLXE3CLZGJRYMYBOU 连云港市 江苏省
|
||||
FS4PIU74F7QKYARDWR5ZMOLICI 苏州市 江苏省
|
||||
R2F4OWUO65HYZW2IQIKINORZ7Y 赣州市 江西省
|
||||
YW346BTN3VFYNRC3744UR5MZXY 抚州市 江西省
|
||||
QR3FDR26U2EJIXOMBHL7IJLQSA 南昌市 江西省
|
||||
OAJHJL7L7VNW2Q5UXRE7F4CUJQ 九江市 江西省
|
||||
OMH7D45R4DX2KNHLV3G2UP56OY 景德镇市 江西省
|
||||
YSB2PAEROB2IZSJZFVFH7KJPEI 鹰潭市 江西省
|
||||
5OYAMNORCXKYA6UF7DW6KFFBIU 上饶市 江西省
|
||||
SMHZOYKE7BXQJ2NT6Q24TFMLEQ 吉安市 江西省
|
||||
2RZV26OUPKUHUJ5ZPB673VDGZU 萍乡市 江西省
|
||||
232VHZEEZ6SXACE4AC5HQ4ZTFQ 新余市 江西省
|
||||
QRLM74YXNDW2QDBWLTFGEMXK2I 宜春市 江西省
|
||||
S6OHUVUKIIWPVMQD44RREUMNT4 葫芦岛市 辽宁省
|
||||
DQQ4OIFUGFYJY3XZRK5VDWMLCA 辽阳市 辽宁省
|
||||
S4YXGFEYXEUG6ISZ6O337OPVSI 阜新市 辽宁省
|
||||
D3JHM7A4CG6RJMBD7YRDS5JOYU 盘锦市 辽宁省
|
||||
VTRWMOSS6PCUYUAIPG6VPBKUUQ 营口市 辽宁省
|
||||
ZPHFGWBIEVLKP5CVZNZUB3CRT4 朝阳市 辽宁省
|
||||
NGYYULZ4UAGD3Q2PG726FFXSHU 抚顺市 辽宁省
|
||||
Q5BRTSW752VSHIAKLLL7KL5TNA 锦州市 辽宁省
|
||||
ZGV3WNPOSS7J4ZWBP6ZQG46BNM 沈阳市 辽宁省
|
||||
XEX676YYMTYIV5QPIUZB4TA7IY 本溪市 辽宁省
|
||||
PRTEQZMLNLQNZXJHRCYYLWZB4E 丹东市 辽宁省
|
||||
4GN4WF6UQRFU64T4FVZPRDXRWQ 鞍山市 辽宁省
|
||||
3QTZDFLJFSLLOOVCZ65PSDAVOU 铁岭市 辽宁省
|
||||
CC4ZTMKKXI73ZEVT5QQTJN5SMM 大连市 辽宁省
|
||||
3MBJEFDLAVOMQZ7L7CM5MNSYKA 鄂尔多斯市 内蒙古自治区
|
||||
5NOS4YC5WO2IZCQPVB6MCBYDJ4 呼和浩特市 内蒙古自治区
|
||||
OQNIP675H7L5R64652BH7KHUOQ 通辽市 内蒙古自治区
|
||||
4WA6I63MGVINV5DNLNWRRHCDDM 阿拉善盟 内蒙古自治区
|
||||
ELI6BDJBAN6RCYTETMK2EX2UKU 乌兰察布市 内蒙古自治区
|
||||
PV5ZAAXFW2DZCVZKCF4I4KK7BQ 巴彦淖尔市 内蒙古自治区
|
||||
YU6UUT6G6T6AMWTJFECDIUQFEQ 乌海市 内蒙古自治区
|
||||
V4MYANW5QFZCXG3FIDPXA3HOTE 呼伦贝尔市 内蒙古自治区
|
||||
S5DCFJWJ7J3MJSLY2PHKWLNPOQ 包头市 内蒙古自治区
|
||||
LY7SAZRFSJJMRU3JEO5SKNKIVM 兴安盟 内蒙古自治区
|
||||
NM2XP54CNQCFOILKACYEQWUSGM 锡林郭勒盟 内蒙古自治区
|
||||
S5G3IO75IDEJPZQA6VFM3OYPDI 赤峰市 内蒙古自治区
|
||||
UUFUUPM5RT6ZU5UKILQC5YQV54 吴忠市 宁夏回族自治区
|
||||
VMSRLIATK44WQXQEWAL63AXJ3M 固原市 宁夏回族自治区
|
||||
4GWWCAAKGNJV2SMQPSWWZNCGYY 中卫市 宁夏回族自治区
|
||||
6IE7GEETBQEF7GUSGU2FLIUEEM 石嘴山市 宁夏回族自治区
|
||||
VI4YIH3URSON4Q4MWOEESXJ56Q 银川市 宁夏回族自治区
|
||||
SIE4ED6QWVRT727GEHWBFH3DAA 海东市 青海省
|
||||
JNJH6OJZIOQKXDXWW5ZGEHG5MA 海西蒙古族藏族自治州 青海省
|
||||
MJADYNCKQNDJU2TXACTDP5I52M 海北藏族自治州 青海省
|
||||
LRGFXIVB6RJWQWYAFH7EIUHCPE 黄南藏族自治州 青海省
|
||||
J4TG3PCK2ZEMNEUMIPZF32UNQY 果洛藏族自治州 青海省
|
||||
2YS5POGG53LKZGFBIUMDWP57SM 玉树藏族自治州 青海省
|
||||
GRZMJEZCA2DNCZK3O6ZSUHMRPM 西宁市 青海省
|
||||
NBFQIACRBBCAH5AZWJ5LVT7AU4 海南藏族自治州 青海省
|
||||
MQUKCLQ76P4FRRECDBA3HBKT7Q 滨州市 山东省
|
||||
633FVSBDDBM5WSMXSKOCX6QC5I 潍坊市 山东省
|
||||
4434FVT3PXLMV6UAWLEW6O3M5A 菏泽市 山东省
|
||||
P7PK4UBVCOHW3PI6IPEIA54DLY 济南市 山东省
|
||||
I5M6JGTGSQEWX6HL7E5I6GRBAY 德州市 山东省
|
||||
V562AOMBVU5NG5GB3EPK6U42XY 烟台市 山东省
|
||||
4OSPHTE5TD24J6DYGR6DXMEDKY 淄博市 山东省
|
||||
227TLAVTUJABWPJD4S4ZECJ3FY 临沂市 山东省
|
||||
DAEZKZU32ZAPJGUTA6LLGO3WTY 聊城市 山东省
|
||||
LBRRK2EOYJN5MLYJWT4R3QBSXM 东营市 山东省
|
||||
AB6PBGCDBTNTG4KUQROY2FJ4GY 枣庄市 山东省
|
||||
EVANGU7WZCVRAAM6NWTDJVP7SU 济宁市 山东省
|
||||
GNUEGWZ3OKRWAKKVJ5THHHX6YY 泰安市 山东省
|
||||
F3VWSF4ART2FYYBBOZYWKRXTUI 青岛市 山东省
|
||||
KD6MNWWLVKB4E655XMV6MMA3KE 日照市 山东省
|
||||
LHYVF4LBCOZ34G3WNZYUVEIQGA 威海市 山东省
|
||||
ENVYDMYGDO3BMDXSAVQYZXLX74 阳泉市 山西省
|
||||
UXOUG4UIF7ZRJYCNMQJ3LDN5FY 临汾市 山西省
|
||||
4NZPT6Z35BMYACJ2HZGHUWRJ6E 吕梁市 山西省
|
||||
KSNXQME2A3VFHCE3DM3SFZKIJQ 晋城市 山西省
|
||||
HDOX7WKYSHJKEHET6TUYMVCTMQ 太原市 山西省
|
||||
DFJIZVXJGBGBIABPSL3DGMIDIE 长治市 山西省
|
||||
5KQYYTJR2EMP653QIALMA6LXXI 忻州市 山西省
|
||||
T76EOJA332RIHML7B6LYS5LF4U 朔州市 山西省
|
||||
HVX67CKT5TS6GPRDFDYOOLK4PE 大同市 山西省
|
||||
S4NGXQJDOH7E4IHDWOH3EK6IIE 晋中市 山西省
|
||||
GWDLZXLAWU54FKQ6G3HRQRR7E4 运城市 山西省
|
||||
3FFTTN5PPV7MBCE5AGY2NGYOOI 安康市 陕西省
|
||||
GACVPL3SWO3ZKH73JMJV6YI4NY 延安市 陕西省
|
||||
6KPS7VRMW57P2DAC6OPR4ISHQQ 商洛市 陕西省
|
||||
OMMF6XLNDNYWG5TBSNTWO2ZJZ4 渭南市 陕西省
|
||||
EALFXGMWYRS6E6TWXQ2K3YHV4M 咸阳市 陕西省
|
||||
WFG7U6JNUWDIS5ZZYM4FSM5C64 榆林市 陕西省
|
||||
R3VBMYTCF5LVHO35X3MYJQFOOE 宝鸡市 陕西省
|
||||
RQOWP7C234IS4RKSHB26IYZ5IU 西安市 陕西省
|
||||
K4YU6B4T5GZLRWVHGVCR3576HI 铜川市 陕西省
|
||||
VVFVAPLKSCN5KN4Q6RK2GPGUUA 汉中市 陕西省
|
||||
2QSF6IG3KMDXWO5VP7FXHMMKXA 上海市 上海市
|
||||
X3JCRNIPTCUU6DGOFFJ4MUK37M 眉山市 四川省
|
||||
GQ24IZNTZJ3PUB5FDAMDA4W7UI 攀枝花市 四川省
|
||||
6B6WT62WHBZRHPQUT7BAD2N6ZI 泸州市 四川省
|
||||
HJ35P4KXL442MLIII7PFFWUNAE 雅安市 四川省
|
||||
K4A6VSJH2AJYT46LMUSVQZPCCU 资阳市 四川省
|
||||
646ZNPATOOM3MHI3LDU6HI4KFI 阿坝藏族羌族自治州 四川省
|
||||
MU735ZDBFPXRQDUZ3I35JK3XEU 内江市 四川省
|
||||
4WPGGJ63USY77GSRN2PPFCYKPQ 广安市 四川省
|
||||
O4FFS4DALDAAKIFAUH4F5V5VS4 宜宾市 四川省
|
||||
IRFJVK2KXBE6BZ7CSN4UFCI624 绵阳市 四川省
|
||||
NELFD7FEKKUNDJ46VLD55SMDCE 甘孜藏族自治州 四川省
|
||||
TKMVEUPZSQCXNRZPBEIK3F45AI 遂宁市 四川省
|
||||
VQW7DPB4KTUI65COJBO3NODU24 巴中市 四川省
|
||||
STP4ELXTVGQSB572LFRFJRIUUY 南充市 四川省
|
||||
6ST5EX2JVXUCLR5GP5VEFSKN5M 成都市 四川省
|
||||
UWNFCMW3HYJRALQI2MJH6EM2O4 德阳市 四川省
|
||||
J5ZYU7XRV6CHSJAGOPQKS5YXNA 达州市 四川省
|
||||
KYJTF5S746T35RFBMR65BLGM6U 凉山彝族自治州 四川省
|
||||
RDUXR23XROLB4NGRVDDLXFXDSE 乐山市 四川省
|
||||
4TUBIBHMVESJUCGMTUSLJPHXWI 广元市 四川省
|
||||
AXQL57AO27NCHYMEOLRHAAKMTA 自贡市 四川省
|
||||
4RXX566RZORCXS6HLEX3DIICSM 花莲县 台湾
|
||||
BD5Y7SISWSSQGP3HTVPU6TXAH4 台东县 台湾
|
||||
I4DNWLECRYOJAZLGYQB7PBBJXQ 台中市 台湾
|
||||
GIZQIESFOMAEQSDQKOEQ5RTTPA 南投县 台湾
|
||||
MPM6M2C634FAW7KYG3KIHERDTU 彰化县 台湾
|
||||
NH2NK6JVOBBYYTK2G53ADWLX4Y 苗栗县 台湾
|
||||
DW2Q2R2UEEDNQA7IHBGYV423K4 新竹市 台湾
|
||||
FX5AOPFRPHGHHYZB4XNIPXLNNM 新北市 台湾
|
||||
UVNZNB6G4M35RV3IGRUN6OXMXE 屏东县 台湾
|
||||
MPB3M2YK24ZORO3EDCJ6UDWIGQ 基隆市 台湾
|
||||
EBHVITJPDHJEEMMZTM4TD4UVRU 台北市 台湾
|
||||
FQS4PZNCTQEX6I5F34Z2AGJUZM 高雄市 台湾
|
||||
WVOJ636Q7MGT6RMN6QYQ4SZWIE 嘉义市 台湾
|
||||
QRLER4EEMMKYGQLER2RWEQA74E 台南市 台湾
|
||||
K2LHF64R2P4OJ7MDHJ6J2NSTPE 桃园市 台湾
|
||||
BILG6LJIWUCTXZ6CDPXYAVM6XI 澎湖县 台湾
|
||||
3FYRA3O2HUMLIQAAJQCPX2TETE 宜兰县 台湾
|
||||
4MW6X22PAPVMHB6SBGF3RYS324 天津市 天津市
|
||||
YEYPP4SQOBXU5UCNDN7ORSR6DI 拉萨市 西藏自治区
|
||||
UNE6UPENGWQDOWGABDJEAQ2FEY 山南市 西藏自治区
|
||||
VDXKN2YCIUPOHBZKKQHPN6HJWE 林芝市 西藏自治区
|
||||
EAWIMNI77H72EYSOAYV3M76CB4 阿里地区 西藏自治区
|
||||
HCF6UHTXOOKIOA43AIJH3ARKXA 昌都市 西藏自治区
|
||||
Y47QI3KJY352QV3VOPXHM2IDWU 日喀则市 西藏自治区
|
||||
R4UWJX44GVAA54NFKHT4Y4ZC5A 那曲市 西藏自治区
|
||||
2D37GB5XUALJDXONWJIGXV3QXU 香港 香港特别行政区
|
||||
PA5W7Z255K3EGYA4LTA7BGLHRA 巴音郭楞蒙古自治州 新疆维吾尔自治区
|
||||
RJZOCU5ECQCOLCOCHJH2UNLQJM 哈密市 新疆维吾尔自治区
|
||||
RYK6AR3VDQJFXZLX3MHYFV5VAI 塔城地区 新疆维吾尔自治区
|
||||
MXUVGU5NPVTNINAKKPNLWUQ54Q 博尔塔拉蒙古自治州 新疆维吾尔自治区
|
||||
T5RKMSH5VIGRTHDZOKV2EIKPIM 伊犁哈萨克自治州 新疆维吾尔自治区
|
||||
NABMKFZPOUCZMS4TUVJSZ24DNA 克孜勒苏柯尔克孜自治州 新疆维吾尔自治区
|
||||
2YQBXWNFYVJX4NU6WXB5II6X34 昌吉回族自治州 新疆维吾尔自治区
|
||||
DBKCQCCQU2URQG2EP5ZNPKBETY 乌鲁木齐市 新疆维吾尔自治区
|
||||
DON6KYBCR2XJQOJQGOZTYV4RMM 阿克苏地区 新疆维吾尔自治区
|
||||
WKG47NEVYII5JISZJN7QSI2BDQ 克拉玛依市 新疆维吾尔自治区
|
||||
DWLK6D3OUOXOVQHSEJVTRIDRAI 喀什地区 新疆维吾尔自治区
|
||||
SAPKF2PQGJD4UMVJZTC3IZKI64 阿勒泰地区 新疆维吾尔自治区
|
||||
ARWSLGG54LGUGN3XMIWW76NW34 吐鲁番市 新疆维吾尔自治区
|
||||
VQCWAKL6ADHYFTSVPBGPDFSB2I 北屯市 新疆维吾尔自治区
|
||||
ES5A6MOROAG6F2XAQ25TYYPWUE 铁门关市 新疆维吾尔自治区
|
||||
36IUY52AESIPF4QEQAR2RTNQYA 和田地区 新疆维吾尔自治区
|
||||
Z26KSUL6ULS65ITZNUCRRWBYJM 文山壮族苗族自治州 云南省
|
||||
XBBUUATPBD2IUJR47FCKVUXB2I 昭通市 云南省
|
||||
TK2LP3JCYYMPTV4OA3WJCH7UQ4 怒江傈僳族自治州 云南省
|
||||
ISJ4FESOYKCQ5LXOQO6NL7TQHA 曲靖市 云南省
|
||||
ELBUBVI5UMUIEQGIOAHPMESXFA 西双版纳傣族自治州 云南省
|
||||
QHOYHEGZM4WSJZPFEU6FCBYIWY 玉溪市 云南省
|
||||
4G4SPJ7MVHMYZAWMQ4642PMLVI 保山市 云南省
|
||||
HCHRV2LGJ2TJ4X6BWNWI2IMID4 普洱市 云南省
|
||||
IS4Q6NASBWHO3RFO7UCIWOXVI4 昆明市 云南省
|
||||
TDJZOAZFQUQPYRR5BHOJTG6RWM 红河哈尼族彝族自治州 云南省
|
||||
EIYC62RNU4SHQW3RLAMDPTQYTI 大理白族自治州 云南省
|
||||
BA3XFPITAYKBUWDKU3QONRHBC4 德宏傣族景颇族自治州 云南省
|
||||
6P6FFFO6C5MLNCVRJAJUICSVQI 临沧市 云南省
|
||||
OXHMWH2TSIDI7BQ43EHAMXJ6N4 丽江市 云南省
|
||||
QQPDT4LBI2K2KMBNXZ6YH7X2FI 楚雄彝族自治州 云南省
|
||||
XBG4EJAWCRJL2TDPNJ23PTFYHQ 迪庆藏族自治州 云南省
|
||||
DINNCH54AP74TJ62MICEYAZP74 宁波市 浙江省
|
||||
XYTSLYGB2ETU6HG7GIXA7X5SOE 嘉兴市 浙江省
|
||||
NNAALJZXGAWALR3LGE2V4UZT6U 丽水市 浙江省
|
||||
H5UOJ5MQYJ737GS3TXYN2OJHUU 杭州市 浙江省
|
||||
HG5VQGOMSCEGNXJXKO6XCNCHMY 湖州市 浙江省
|
||||
LJ2SWEPRINTYDH5A2QHRMI5US4 衢州市 浙江省
|
||||
TW4RRM62TDA7WWU77FDSLSAXGY 台州市 浙江省
|
||||
HBBN247QZ6YUW5ZYSS6D7RVJCA 绍兴市 浙江省
|
||||
GVFB23SJZGRPRXXTIKDCAOCDPI 金华市 浙江省
|
||||
UEW4ENX7N7IFGFM7FD5SZ5GI7Y 舟山市 浙江省
|
||||
HCCXS5DGRJQMYZMRLGVAMUIQEA 温州市 浙江省
|
||||
FDGY55I6IHKY76E3MWDBOT2R6Y 重庆市 重庆市
|
||||
@@ -0,0 +1,65 @@
|
||||
"""通过经纬度反查城市(reverse_geocoder 离线库,零网络调用)。
|
||||
|
||||
reverse_geocoder 内置 ~2.5M 条全球城市/聚居点的经纬度→地名映射表,
|
||||
构建一次 KDTree(~几十MB 内存)后,查询为纯内存搜索,不作任何外部网络调用。
|
||||
|
||||
⚠️ 必须持有单例、且用 mode=1(单进程):
|
||||
- reverse_geocoder 的模块级 rg.search()/rg.get() **每次调用都会 new 一个 RGeocoder**,
|
||||
即每次都重新解析 ~2.5M 行 CSV + 重建 KDTree(数秒/次)。绝不能在服务端按请求调用。
|
||||
- 默认 mode=2 用 multiprocessing 按 CPU 数 spawn 子进程做并行查询;在服务端 / Windows
|
||||
spawn 下会重复 import 主模块(无 __main__ guard 时直接报错),既慢又危险。
|
||||
故本模块持有一个 mode=1 的 RGeocoder 单例,建一次树、复用;查询走单进程内存搜索。
|
||||
|
||||
⚠️ 精度说明:gazetteer 里的中国数据粒度不一致——直辖市/省会通常直接命中城市名,
|
||||
但部分城市会命中到区/街道级(如天津→Erwangzhuang、西安→Zhangjiabao),
|
||||
此时 admin1(省级行政区)可作为回退。业务侧建议优先用 admin1 做城市级判定。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import reverse_geocoder as rg # type: ignore[import-untyped]
|
||||
|
||||
# mode=1 单进程 KDTree 的单例;None 表示尚未构建(见 ensure_loaded)。
|
||||
_geocoder: rg.RGeocoder | None = None
|
||||
|
||||
|
||||
def ensure_loaded() -> None:
|
||||
"""构建(或复用)RGeocoder 单例(幂等)。
|
||||
|
||||
首次调用解析 ~2.5M 行 CSV + 构建 KDTree(~秒级、数十 MB)。生产应在 main.py 的
|
||||
lifespan 启动阶段主动调用一次,把这份一次性成本摊到启动,避免砸在第一个
|
||||
/feed?tab=rec / /top-sales 请求上。mode=1 = 单进程,不 spawn 子进程。
|
||||
"""
|
||||
global _geocoder
|
||||
if _geocoder is None:
|
||||
_geocoder = rg.RGeocoder(mode=1, verbose=False)
|
||||
|
||||
|
||||
def get_city(latitude: float, longitude: float) -> dict[str, str]:
|
||||
"""根据经纬度反查最近聚居点。
|
||||
|
||||
返回 dict:
|
||||
- name: 最近聚居点名称(英文),如 "Beijing" / "Fengsheng";
|
||||
中国境内可能是区/街道级;海洋/无人区返 ""
|
||||
- admin1: 省级行政区(英文),如 "Beijing" / "Hubei" / "Chongqing Shi";
|
||||
直辖市 admin1 即为城市名
|
||||
- country: ISO 3166-1 alpha-2,如 "CN"
|
||||
- latitude: 匹配到的参考点纬度(字符串)
|
||||
- longitude: 匹配到的参考点经度(字符串)
|
||||
|
||||
未匹配到(海洋/远洋)时返回空字符串字段。
|
||||
"""
|
||||
ensure_loaded()
|
||||
assert _geocoder is not None # ensure_loaded 保证已构建
|
||||
results: list[dict[str, Any]] = _geocoder.query([(latitude, longitude)])
|
||||
if not results:
|
||||
return {"name": "", "admin1": "", "country": "", "latitude": "", "longitude": ""}
|
||||
r = results[0]
|
||||
return {
|
||||
"name": str(r.get("name", "")),
|
||||
"admin1": str(r.get("admin1", "")),
|
||||
"country": str(r.get("cc", "")),
|
||||
"latitude": str(r.get("lat", "")),
|
||||
"longitude": str(r.get("lon", "")),
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
"""美团城市词典 + reverse_geocoder 离线反查。
|
||||
|
||||
从 feed 入参的 latitude/longitude 计算出美团城市 ID,
|
||||
用于后续美团 CPS 接口的 cityId 参数。
|
||||
|
||||
⚠️ 跨系统耦合:本模块返回的 city_id 取自 data/city_dict.txt,而离线库
|
||||
`meituan_coupon.city_id` 由 ETL(另一套系统)灌入。二者必须用同一份城市 ID 口径,
|
||||
否则 `WHERE city_id == <本模块结果>` 会静默查到 0 行 → 接口永久降级返空。
|
||||
改动 city_dict.txt 或 ETL 的城市 ID 来源时,务必同步两侧。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from app.utils.geo import get_city as _get_geo_city
|
||||
|
||||
logger = logging.getLogger("shagua.meituan_city")
|
||||
|
||||
# city_dict.txt 作为运行时数据随包分发(见 pyproject [tool.setuptools.package-data])
|
||||
_CITY_DICT_PATH = Path(__file__).resolve().parent / "data" / "city_dict.txt"
|
||||
|
||||
# ─────────── 反向地理编码 admin1 → 中文省份名 ───────────
|
||||
# reverse_geocoder 的 admin1 格式不统一:
|
||||
# 直辖市: "Beijing" / "Shanghai Shi" / "Tianjin Shi" / "Chongqing Shi"
|
||||
# 省份: "Guangdong" / "Jiangsu Sheng" / "Hubei" ...
|
||||
# 自治区: "Xinjiang Uygur Zizhiqu" / "Tibet Autonomous Region" ...
|
||||
# 下面用前缀匹配,去掉了 Sheng/Shi/Zizhiqu/Autonomous Region 等后缀。
|
||||
|
||||
_PROVINCE_EN_PREFIX: list[tuple[str, str]] = [
|
||||
# 直辖市 — admin1 即城市名
|
||||
("Beijing", "北京市"),
|
||||
("Shanghai", "上海市"),
|
||||
("Tianjin", "天津市"),
|
||||
("Chongqing", "重庆市"),
|
||||
# 省
|
||||
("Hebei", "河北省"),
|
||||
("Shanxi", "山西省"), # 注意: 指山西省,不是陕西
|
||||
("Liaoning", "辽宁省"),
|
||||
("Jilin", "吉林省"),
|
||||
("Heilongjiang", "黑龙江省"),
|
||||
("Jiangsu", "江苏省"),
|
||||
("Zhejiang", "浙江省"),
|
||||
("Anhui", "安徽省"),
|
||||
("Fujian", "福建省"),
|
||||
("Jiangxi", "江西省"),
|
||||
("Shandong", "山东省"),
|
||||
("Henan", "河南省"),
|
||||
("Hubei", "湖北省"),
|
||||
("Hunan", "湖南省"),
|
||||
("Guangdong", "广东省"),
|
||||
("Hainan", "海南省"),
|
||||
("Sichuan", "四川省"),
|
||||
("Guizhou", "贵州省"),
|
||||
("Yunnan", "云南省"),
|
||||
("Shaanxi", "陕西省"), # 双写 a 是官方拼音
|
||||
("Gansu", "甘肃省"),
|
||||
("Qinghai", "青海省"),
|
||||
# 自治区 — 注意匹配顺序, Xinjiang 要在 Guangxi 前面(Guangxi 也是 Xi 开头但先匹配 Xin 不会误判)
|
||||
("Guangxi", "广西壮族自治区"),
|
||||
("Inner Mongolia", "内蒙古自治区"),
|
||||
("Nei Mongol", "内蒙古自治区"),
|
||||
("Tibet", "西藏自治区"),
|
||||
("Xizang", "西藏自治区"),
|
||||
("Ningxia", "宁夏回族自治区"),
|
||||
("Xinjiang", "新疆维吾尔自治区"),
|
||||
# 特别行政区
|
||||
("Hong Kong", "香港特别行政区"),
|
||||
("Macau", "澳门特别行政区"),
|
||||
("Macao", "澳门特别行政区"),
|
||||
# 台湾(city_dict 里省份名为 "台湾",没有省/自治区后缀)
|
||||
("Taiwan", "台湾"),
|
||||
]
|
||||
|
||||
# ─────────── 常见城市名 英文→中文 映射 ───────────
|
||||
# 覆盖所有直辖市 + 省会 + 一线城市 + 部分 reverse_geocoder 只能命中到区/县的城市。
|
||||
# key 全小写,匹配时做小写比较。
|
||||
_CITY_EN_TO_CN: dict[str, str] = {
|
||||
# 直辖市
|
||||
"beijing": "北京市",
|
||||
"shanghai": "上海市",
|
||||
"tianjin": "天津市",
|
||||
"chongqing": "重庆市",
|
||||
# 省会 / 副省级
|
||||
"guangzhou": "广州市",
|
||||
"shenzhen": "深圳市",
|
||||
"chengdu": "成都市",
|
||||
"hangzhou": "杭州市",
|
||||
"wuhan": "武汉市",
|
||||
"xi'an": "西安市",
|
||||
"nanjing": "南京市",
|
||||
"changsha": "长沙市",
|
||||
"zhengzhou": "郑州市",
|
||||
"jinan": "济南市",
|
||||
"kunming": "昆明市",
|
||||
"fuzhou": "福州市",
|
||||
"harbin": "哈尔滨市",
|
||||
"lanzhou": "兰州市",
|
||||
"guiyang": "贵阳市",
|
||||
"nanning": "南宁市",
|
||||
"shijiazhuang": "石家庄市",
|
||||
"taiyuan": "太原市",
|
||||
"shenyang": "沈阳市",
|
||||
"changchun": "长春市",
|
||||
"hefei": "合肥市",
|
||||
"nanchang": "南昌市",
|
||||
"haikou": "海口市",
|
||||
"hohhot": "呼和浩特市",
|
||||
"huhehaote": "呼和浩特市",
|
||||
"urumqi": "乌鲁木齐市",
|
||||
"wulumuqi": "乌鲁木齐市",
|
||||
"lhasa": "拉萨市",
|
||||
"yinchuan": "银川市",
|
||||
"xining": "西宁市",
|
||||
# 其他常见城市
|
||||
"xiamen": "厦门市",
|
||||
"suzhou": "苏州市",
|
||||
"qingdao": "青岛市",
|
||||
"dalian": "大连市",
|
||||
"ningbo": "宁波市",
|
||||
"wuxi": "无锡市",
|
||||
"foshan": "佛山市",
|
||||
"dongguan": "东莞市",
|
||||
"zhuhai": "珠海市",
|
||||
"zhongshan": "中山市",
|
||||
"wenzhou": "温州市",
|
||||
"shaoxing": "绍兴市",
|
||||
"jiaxing": "嘉兴市",
|
||||
"jinhua": "金华市",
|
||||
"taizhou": "台州市",
|
||||
"yangzhou": "扬州市",
|
||||
"nantong": "南通市",
|
||||
"changzhou": "常州市",
|
||||
"xuzhou": "徐州市",
|
||||
"zhengjiang": "镇江市",
|
||||
"yantai": "烟台市",
|
||||
"weifang": "潍坊市",
|
||||
"zibo": "淄博市",
|
||||
"linyi": "临沂市",
|
||||
"weihai": "威海市",
|
||||
"rizhao": "日照市",
|
||||
"luoyang": "洛阳市",
|
||||
"kaifeng": "开封市",
|
||||
"xinxiang": "新乡市",
|
||||
"nanyang": "南阳市",
|
||||
"yichang": "宜昌市",
|
||||
"xiangyang": "襄阳市",
|
||||
"huangshi": "黄石市",
|
||||
"zhuzhou": "株洲市",
|
||||
"xiangtan": "湘潭市",
|
||||
"yueyang": "岳阳市",
|
||||
"hengyang": "衡阳市",
|
||||
"mianyang": "绵阳市",
|
||||
"luzhou": "泸州市",
|
||||
"yibin": "宜宾市",
|
||||
"nanchong": "南充市",
|
||||
"zigong": "自贡市",
|
||||
"qujing": "曲靖市",
|
||||
"yuxi": "玉溪市",
|
||||
"zunyi": "遵义市",
|
||||
"guilin": "桂林市",
|
||||
"liuzhou": "柳州市",
|
||||
"sanya": "三亚市",
|
||||
"tangshan": "唐山市",
|
||||
"baoding": "保定市",
|
||||
"handan": "邯郸市",
|
||||
"qinhuangdao": "秦皇岛市",
|
||||
"langfang": "廊坊市",
|
||||
"datong": "大同市",
|
||||
"changzhi": "长治市",
|
||||
"linfen": "临汾市",
|
||||
"baotou": "包头市",
|
||||
"ordos": "鄂尔多斯市",
|
||||
"eerduosi": "鄂尔多斯市",
|
||||
"daqing": "大庆市",
|
||||
"qiqihar": "齐齐哈尔市",
|
||||
"jilin_city": "吉林市",
|
||||
"anshan": "鞍山市",
|
||||
"fushun": "抚顺市",
|
||||
"benxi": "本溪市",
|
||||
"jinzhou": "锦州市",
|
||||
"yingkou": "营口市",
|
||||
"dandong": "丹东市",
|
||||
"huizhou": "惠州市",
|
||||
"jiangmen": "江门市",
|
||||
"zhanjiang": "湛江市",
|
||||
"maoming": "茂名市",
|
||||
"zhaoqing": "肇庆市",
|
||||
"chaozhou": "潮州市",
|
||||
"shantou": "汕头市",
|
||||
"shaoguan": "韶关市",
|
||||
"meizhou": "梅州市",
|
||||
"jieyang": "揭阳市",
|
||||
"qingyuan": "清远市",
|
||||
"heyuan": "河源市",
|
||||
"yangjiang": "阳江市",
|
||||
"shanwei": "汕尾市",
|
||||
"yunfu": "云浮市",
|
||||
}
|
||||
|
||||
|
||||
# ─────────── 省会映射(城市匹配失败时回退) ───────────
|
||||
# city_dict.txt 内省份的第一个城市不一定是省会,故显式维护。
|
||||
_PROVINCE_CAPITAL: dict[str, str] = {
|
||||
"安徽省": "合肥市",
|
||||
"澳门特别行政区": "澳门",
|
||||
"北京市": "北京市",
|
||||
"福建省": "福州市",
|
||||
"甘肃省": "兰州市",
|
||||
"广东省": "广州市",
|
||||
"广西壮族自治区": "南宁市",
|
||||
"贵州省": "贵阳市",
|
||||
"海南省": "海口市",
|
||||
"河北省": "石家庄市",
|
||||
"河南省": "郑州市",
|
||||
"黑龙江省": "哈尔滨市",
|
||||
"湖北省": "武汉市",
|
||||
"湖南省": "长沙市",
|
||||
"吉林省": "长春市",
|
||||
"江苏省": "南京市",
|
||||
"江西省": "南昌市",
|
||||
"辽宁省": "沈阳市",
|
||||
"内蒙古自治区": "呼和浩特市",
|
||||
"宁夏回族自治区": "银川市",
|
||||
"青海省": "西宁市",
|
||||
"山东省": "济南市",
|
||||
"山西省": "太原市",
|
||||
"陕西省": "西安市",
|
||||
"上海市": "上海市",
|
||||
"四川省": "成都市",
|
||||
"台湾": "台北市",
|
||||
"天津市": "天津市",
|
||||
"西藏自治区": "拉萨市",
|
||||
"香港特别行政区": "香港",
|
||||
"新疆维吾尔自治区": "乌鲁木齐市",
|
||||
"云南省": "昆明市",
|
||||
"浙江省": "杭州市",
|
||||
"重庆市": "重庆市",
|
||||
}
|
||||
|
||||
|
||||
# ─────────── 城市字典加载 ───────────
|
||||
|
||||
def _parse_city_dict(path: str | Path) -> list[dict[str, str]]:
|
||||
"""解析 city_dict.txt,返回 [{city_id, city_name, province_name}, ...]。
|
||||
|
||||
city_dict.txt 格式(TSV):
|
||||
城市ID\t城市名称\t省份名称
|
||||
|
||||
示例行:
|
||||
3NUYJKKJXPHVNZUHFK3HWUDHNM\t宣城市\t安徽省
|
||||
"""
|
||||
data: list[dict[str, str]] = []
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split("\t")
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
city_id, city_name, province_name = parts[0], parts[1], parts[2]
|
||||
if city_id == "城市ID":
|
||||
continue # 跳过表头
|
||||
if city_id and city_name and province_name:
|
||||
data.append({
|
||||
"city_id": city_id,
|
||||
"city_name": city_name,
|
||||
"province_name": province_name,
|
||||
})
|
||||
return data
|
||||
|
||||
|
||||
# 模块加载时一次解析
|
||||
try:
|
||||
_CITY_DICT: list[dict[str, str]] = _parse_city_dict(_CITY_DICT_PATH)
|
||||
except Exception:
|
||||
logger.exception("加载 city_dict.txt 失败,美团城市反查将不可用")
|
||||
_CITY_DICT = []
|
||||
|
||||
|
||||
def _build_province_index() -> dict[str, list[dict[str, str]]]:
|
||||
"""构建 省份名 → 该省全部城市列表 的索引。"""
|
||||
idx: dict[str, list[dict[str, str]]] = {}
|
||||
for entry in _CITY_DICT:
|
||||
idx.setdefault(entry["province_name"], []).append(entry)
|
||||
return idx
|
||||
|
||||
|
||||
_PROVINCE_INDEX: dict[str, list[dict[str, str]]] | None = None
|
||||
|
||||
|
||||
def _get_province_index() -> dict[str, list[dict[str, str]]]:
|
||||
global _PROVINCE_INDEX
|
||||
if _PROVINCE_INDEX is None:
|
||||
_PROVINCE_INDEX = _build_province_index()
|
||||
return _PROVINCE_INDEX
|
||||
|
||||
|
||||
# ─────────── 查询 ───────────
|
||||
|
||||
def _map_admin1_to_cn_province(admin1: str) -> str:
|
||||
"""将 reverse_geocoder 的 admin1 映射到 city_dict 中的中文省份名。"""
|
||||
if not admin1:
|
||||
return ""
|
||||
normalized = admin1.strip()
|
||||
# 多级匹配:先精确、再前缀
|
||||
for en_prefix, cn_name in _PROVINCE_EN_PREFIX:
|
||||
if normalized == en_prefix or normalized.startswith(en_prefix):
|
||||
return cn_name
|
||||
return ""
|
||||
|
||||
|
||||
def _lookup_city_in_province(city_en_lower: str, province_cn: str) -> str:
|
||||
"""在指定省份内查找匹配的城市名(EN→CN 映射)。"""
|
||||
if not province_cn:
|
||||
return ""
|
||||
index = _get_province_index()
|
||||
candidates = index.get(province_cn, [])
|
||||
if not candidates:
|
||||
return ""
|
||||
|
||||
# 1) 精确映射
|
||||
if city_en_lower in _CITY_EN_TO_CN:
|
||||
cn_city = _CITY_EN_TO_CN[city_en_lower]
|
||||
for c in candidates:
|
||||
if c["city_name"] == cn_city:
|
||||
return cn_city
|
||||
|
||||
# 2) 前缀/包含匹配(处理 admin1 直辖市场景:行政区 → 直辖市本身)
|
||||
for c in candidates:
|
||||
# 去掉"市"后缀比较
|
||||
city_core = c["city_name"].rstrip("市")
|
||||
if city_en_lower.startswith(city_core.lower()) or city_core.lower().startswith(city_en_lower):
|
||||
return c["city_name"]
|
||||
# city_en_lower 可能是拼音,city_core 是中文,尝试从 EN→CN 映射反向匹配
|
||||
for en_k, cn_v in _CITY_EN_TO_CN.items():
|
||||
if cn_v == c["city_name"] and (city_en_lower in en_k or en_k in city_en_lower):
|
||||
return cn_v
|
||||
|
||||
# 3) 匹配不到 → 返回省会
|
||||
capital = _PROVINCE_CAPITAL.get(province_cn, "")
|
||||
if capital:
|
||||
for c in candidates:
|
||||
if c["city_name"] == capital:
|
||||
return capital
|
||||
return candidates[0]["city_name"] # 终极兜底
|
||||
|
||||
|
||||
def _sanitize_city_name(name: str) -> str:
|
||||
"""去除 reverse_geocoder name 中常见的行政后缀使匹配更鲁棒。"""
|
||||
# 去掉 " District" / " Qu" / " Shi" 等英文后缀
|
||||
for suffix in ("District", "Qu", "Shi", "Sheng", "Xian", "Cun", "Zhen", "Xiang",
|
||||
"Zizhiqu", "Autonomous Region", "Special Administrative Region"):
|
||||
name = re.sub(rf"\s+{suffix}$", "", name, flags=re.IGNORECASE)
|
||||
return name.strip()
|
||||
|
||||
|
||||
@lru_cache(maxsize=512)
|
||||
def _resolve_meituan_city(latitude: float, longitude: float) -> dict[str, str]:
|
||||
"""反查实现;入参已量化(见 get_meituan_city),故 lru_cache 命中率高。
|
||||
|
||||
返回的 dict 被缓存复用 —— 调用方勿原地修改(get_meituan_city 已返回副本)。
|
||||
"""
|
||||
if not _CITY_DICT:
|
||||
return {"city_id": "", "city_name": "", "province_name": ""}
|
||||
|
||||
logger.debug("resolve_meituan_city: lat=%.2f lon=%.2f", latitude, longitude)
|
||||
geo = _get_geo_city(latitude, longitude)
|
||||
name_en = _sanitize_city_name(geo.get("name", ""))
|
||||
admin1 = geo.get("admin1", "")
|
||||
country = geo.get("country", "")
|
||||
|
||||
if country != "CN":
|
||||
logger.debug("resolve_meituan_city: 坐标(%.2f,%.2f)不在中国境内(country=%s)", latitude, longitude, country)
|
||||
return {"city_id": "", "city_name": "", "province_name": ""}
|
||||
|
||||
# 1) 映射省份
|
||||
province_cn = _map_admin1_to_cn_province(admin1)
|
||||
if not province_cn:
|
||||
logger.warning("get_meituan_city: admin1=%r 无法映射到中文省份", admin1)
|
||||
return {"city_id": "", "city_name": "", "province_name": ""}
|
||||
|
||||
# 2) 查找城市
|
||||
name_lower = name_en.lower()
|
||||
city_cn = _lookup_city_in_province(name_lower, province_cn)
|
||||
|
||||
# 3) 按省份+城市匹配 city_dict 中的城市 ID
|
||||
index = _get_province_index()
|
||||
candidates = index.get(province_cn, [])
|
||||
for c in candidates:
|
||||
if city_cn and c["city_name"] == city_cn:
|
||||
return {
|
||||
"city_id": c["city_id"],
|
||||
"city_name": c["city_name"],
|
||||
"province_name": province_cn,
|
||||
}
|
||||
|
||||
# 4) 最终回退:返回该省省会
|
||||
if candidates:
|
||||
capital = _PROVINCE_CAPITAL.get(province_cn, "")
|
||||
if capital:
|
||||
for c in candidates:
|
||||
if c["city_name"] == capital:
|
||||
logger.info("get_meituan_city: 城市匹配失败 name_en=%r, 回退到省会 %s", name_en, capital)
|
||||
return {
|
||||
"city_id": c["city_id"],
|
||||
"city_name": capital,
|
||||
"province_name": province_cn,
|
||||
}
|
||||
# 终极兜底:第一个城市
|
||||
fallback = candidates[0]
|
||||
logger.info("get_meituan_city: 城市匹配失败 name_en=%r, 回退到 %s", name_en, fallback["city_name"])
|
||||
return {
|
||||
"city_id": fallback["city_id"],
|
||||
"city_name": fallback["city_name"],
|
||||
"province_name": province_cn,
|
||||
}
|
||||
|
||||
return {"city_id": "", "city_name": "", "province_name": ""}
|
||||
|
||||
|
||||
def get_meituan_city(latitude: float, longitude: float) -> dict[str, str]:
|
||||
"""根据经纬度反查美团城市 ID + 城市名 + 省份名(对外入口)。
|
||||
|
||||
返回:
|
||||
- city_id: 美团城市 ID(如 3NUYJKKJXPHVNZUHFK3HWUDHNM);
|
||||
匹配失败时返回 ""
|
||||
- city_name: 中文城市名(如 "北京市")
|
||||
- province_name: 中文省份名(如 "北京市")
|
||||
|
||||
原理:
|
||||
1. reverse_geocoder 根据经纬度查出英文地名 + 省份
|
||||
2. 英文省份→中文省份映射(前缀匹配)
|
||||
3. 英文地名→中文城市名映射(精确映射 + 省内候选回退)
|
||||
4. 在 city_dict.txt 中按省份+城市名匹配城市 ID
|
||||
|
||||
城市名匹配失败的策略:
|
||||
- 直辖市(京沪津渝): admin1 本身即城市名,直接取
|
||||
- 省会: 回退到该省第一个城市(city_dict.txt 中每个省的省会通常排第一位)
|
||||
|
||||
实现说明:先把坐标量化到 ~1km(round 到 2 位小数)再进 lru_cache —— 原始 GPS 坐标
|
||||
每次抖动到小数点后 5~6 位,直接做缓存 key 几乎不命中;城市级解析对 1km 误差不敏感,
|
||||
量化后"同一地点反复请求"可命中缓存。返回缓存 dict 的副本,调用方可安全读写。
|
||||
"""
|
||||
return dict(_resolve_meituan_city(round(latitude, 2), round(longitude, 2)))
|
||||
+40
-44
@@ -3,7 +3,7 @@
|
||||
> Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770`
|
||||
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**
|
||||
> 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer <access_token>`
|
||||
> 最后更新:2026-07-03(补全缺失文档:ad/watch-report, wallet/transfer-auth 族, coupon/session+stats+completed-today+prompt 族, invite 族, user/onboarding, platform/flags+ad-config+app-version, intent/step+precoupon/step, analytics/events, order/report, report 族, feedback/config+records, trace/finalize。文档移至分类子目录,新增 mock 入参/出参示例)
|
||||
> 最后更新:2026-07-09(① 比价透传改「软鉴权 + trace_id 签发 + harvest 落库」(#112 尾声帧 `trace/epilogue` 一并补录);② 新端点:`user/onboarding/reset`(#114)、`GET /internal/launch-confirm-samples`(#91);③ 参数更新:提现族 `source` 分账(#82/#121)、`wallet/account` 邀请奖励金余额、美团 feed/top-sales 按城市过滤(#116)、admin 调现金 `account` 目标账户(#95);④ **Admin 索引补全到当前全量**:新家族 roles(#117/#126)/coupon-data(#99)/device-liveness(#80)/event-logs(#83)/price-reports(#94)/CPS 运营台/提现审核族,及 feedbacks 采纳拒绝(#94/#105)、marquee 模式与真实条浏览(#122/#123)等。上一次 2026-07-03)
|
||||
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
|
||||
|
||||
---
|
||||
@@ -27,17 +27,18 @@
|
||||
| 8e | `GET /api/v1/coupon/completed-today` | 无 | [详情](./coupon/coupon-completed-today.md)(这台设备今天是否已跑完整轮领券) |
|
||||
| 8f | `POST /api/v1/coupon/completed-today/reset` | 无 | [详情](./coupon/coupon-completed-today.md)(重置今日已完成,开发用) |
|
||||
| 8g | `GET /api/v1/coupon/stats` | Bearer | [详情](./coupon/coupon-stats.md)(累计领券数,「我的」页战绩卡) |
|
||||
| 8h | `POST /api/v1/coupon/session` | 无 | [详情](./coupon/coupon/coupon-session.md)(领券流水上报,admin 看板数据源) |
|
||||
| 8h | `POST /api/v1/coupon/session` | 无 | [详情](./coupon/coupon-session.md)(领券流水上报,admin 看板数据源) |
|
||||
| 9 | `POST /api/v1/meituan/coupons` | 无 | [详情](./meituan/meituan-coupons.md) |
|
||||
| 10 | `POST /api/v1/meituan/feed` | 无 | [详情](./meituan/meituan-feed.md) |
|
||||
| 10 | `POST /api/v1/meituan/feed` | 无 | [详情](./meituan/meituan-feed.md)(`rec` tab 离线库 + **按城市过滤** #116) |
|
||||
| 11 | `POST /api/v1/meituan/referral-link` | 无 | [详情](./meituan/meituan-referral-link.md) |
|
||||
| 11a | `POST /api/v1/meituan/top-sales` | 无 | [详情](./meituan/meituan-top-sales.md)(销量榜:离线库 `meituan_coupon` 按销量降序 + 跨源去重,不实时打美团) |
|
||||
| **比价透传**(前缀 `/api/v1`,外卖 MVP;与 `coupon/step` 同为透传 pricebot-backend;下按 Phase 流程列,均不鉴权) |||
|
||||
| 12 | `POST /api/v1/intent/recognize` | 无 | [详情](./intent/compare-intent-recognize.md)(Phase 1 意图识别,单次,多数源) |
|
||||
| 12a | `POST /api/v1/intent/precoupon/step` | 无 | [详情](./intent/intent-step.md)(Phase 0 意图识别前先用券,仅美团源) |
|
||||
| 12b | `POST /api/v1/intent/step` | 无 | [详情](./intent/intent-step.md)(Phase 1 多帧意图识别,仅淘宝源,循环到 done) |
|
||||
| 13 | `POST /api/v1/price/step` | 无 | [详情](./intent/compare-price-step.md)(Phase 2 步进) |
|
||||
| 13a | `POST /api/v1/trace/finalize` | 无 | [详情](./other/trace-finalize.md)(比价 trace 收尾上云,终止/未识别拿 trace_url) |
|
||||
| 11a | `POST /api/v1/meituan/top-sales` | 无 | [详情](./meituan/meituan-top-sales.md)(同城销量榜:离线库按销量降序 + 跨源去重 + 城市过滤 #116,不实时打美团) |
|
||||
| **比价透传**(前缀 `/api/v1`,透传 pricebot-backend;**软鉴权 OptionalUser** + 首帧签发 trace_id + harvest 落 `comparison_record`,2026-07 起不再是纯透传) |||
|
||||
| 12 | `POST /api/v1/intent/recognize` | 软 | [详情](./intent/compare-intent-recognize.md)(Phase 1 意图识别,单次,多数源;mint 帧建 running 行) |
|
||||
| 12a | `POST /api/v1/intent/precoupon/step` | 软 | [详情](./intent/intent-step.md)(Phase 0 意图识别前先用券,仅美团源) |
|
||||
| 12b | `POST /api/v1/intent/step` | 软 | [详情](./intent/intent-step.md)(Phase 1 多帧意图识别,仅淘宝源,循环到 done) |
|
||||
| 13 | `POST /api/v1/price/step` | 软 | [详情](./intent/compare-price-step.md)(Phase 2 步进;done 帧 harvest 写终态) |
|
||||
| 13a | `POST /api/v1/trace/finalize` | 软 | [详情](./other/trace-finalize.md)(比价 trace 收尾上云 + 夭折落库,终止/未识别拿 trace_url) |
|
||||
| 13b | `POST /api/v1/trace/epilogue` | 软 | [详情](./other/trace-finalize.md)(结果页尾声帧:App 结果页截图入 trace,纯透传不落库,#112) |
|
||||
| **比价记录**(前缀 `/api/v1/compare`;按用户落库,**鉴权**,区别于上面不鉴权的透传) |||
|
||||
| 12a | `POST /api/v1/compare/record` | Bearer | [详情](./compare/compare-record-report.md) |
|
||||
| 12b | `GET /api/v1/compare/records` | Bearer | [详情](./compare/compare-records.md) |
|
||||
@@ -54,7 +55,7 @@
|
||||
| **上报更低价**(前缀 `/api/v1/report`;众包纠偏,人工审核发奖) |||
|
||||
| R1 | `POST /api/v1/report` | Bearer | [详情](./other/report-submit.md)(提交上报更低价,multipart:比价记录ID+平台+价格+截图1-4张) |
|
||||
| R2 | `GET /api/v1/report/records` | Bearer | [详情](./other/report-records.md)(上报记录列表,?status=pending/approved/rejected 可选筛选) |
|
||||
| **好友邀请**(前缀 `/api/v1/invite`;注册即生效,双方各发 1 万金币) |||
|
||||
| **好友邀请**(前缀 `/api/v1/invite`;绑定注册即生效但**不发奖**,#113 起好友「比价并下单」才给邀请人发**邀请奖励金**,经 `POST /order/report` 触发) |||
|
||||
| I1 | `GET /api/v1/invite/me` | Bearer | [详情](./invite/invite-me.md)(我的邀请码+分享链接+已邀人数/已得金币) |
|
||||
| I2 | `GET /api/v1/invite/invitees` | Bearer | [详情](./invite/invite-invitees.md)(我邀请的人列表,limit/offset 分页) |
|
||||
| I3 | `POST /api/v1/invite/landing-track` | 无 | [详情](./invite/invite-bind.md)(落地页 dl.html 访问上报指纹,剪贴板归因兜底;浏览器无 token) |
|
||||
@@ -68,9 +69,9 @@
|
||||
| 19 | `POST /api/v1/wallet/bind-wechat` | Bearer | [详情](./wallet/wallet-bind-wechat.md) |
|
||||
| 20 | `POST /api/v1/wallet/unbind-wechat` | Bearer | [详情](./wallet/wallet-unbind-wechat.md) |
|
||||
| 21 | `GET /api/v1/wallet/withdraw-info` | Bearer | [详情](./wallet/wallet-withdraw-info.md) |
|
||||
| 22 | `POST /api/v1/wallet/withdraw` | Bearer | [详情](./wallet/wallet-withdraw.md) |
|
||||
| 22 | `POST /api/v1/wallet/withdraw` | Bearer | [详情](./wallet/wallet-withdraw.md)(`source` 分账:coin_cash / invite_cash,#121) |
|
||||
| 23 | `GET /api/v1/wallet/withdraw/status` | Bearer | [详情](./wallet/wallet-withdraw-status.md) |
|
||||
| 24 | `GET /api/v1/wallet/withdraw-orders` | Bearer | [详情](./wallet/wallet-withdraw-orders.md) |
|
||||
| 24 | `GET /api/v1/wallet/withdraw-orders` | Bearer | [详情](./wallet/wallet-withdraw-orders.md)(可按 `source` 过滤) |
|
||||
| 24a | `POST /api/v1/wallet/transfer-auth` | Bearer | [详情](./wallet/wallet-transfer-auth.md)(开启免确认到账,申请授权,返回拉起微信授权页的 package) |
|
||||
| 24b | `GET /api/v1/wallet/transfer-auth/status` | Bearer | [详情](./wallet/wallet-transfer-auth.md)(查免确认授权状态,从微信授权页返回后轮询) |
|
||||
| 24c | `POST /api/v1/wallet/transfer-auth/close` | Bearer | [详情](./wallet/wallet-transfer-auth.md)(关闭免确认到账,解除授权) |
|
||||
@@ -99,6 +100,7 @@
|
||||
| 36 | `POST /api/v1/user/avatar` | Bearer | [详情](./user/user-avatar.md) |
|
||||
| 36a | `POST /api/v1/user/onboarding/complete` | Bearer | [详情](./user/user-onboarding.md)(标记新手引导完成,按 账号+device_id 幂等,跨卸载重装持久) |
|
||||
| 36b | `GET /api/v1/user/onboarding/status` | Bearer | [详情](./user/user-onboarding.md)(查该 账号+设备 是否走过引导,运营在 admin 删记录即触发重走) |
|
||||
| 36c | `POST /api/v1/user/onboarding/reset` | Bearer | [详情](./user/user-onboarding.md)(重置本设备引导标记,下次登录重走,#114) |
|
||||
| 37 | `DELETE /api/v1/user` | Bearer | [详情](./user/user-delete.md) |
|
||||
| **帮助与反馈**(前缀 `/api/v1/feedback`) |||
|
||||
| 38 | `POST /api/v1/feedback` | Bearer | [详情](./other/feedback.md) |
|
||||
@@ -127,43 +129,37 @@
|
||||
| N4 | `POST /internal/store-mapping/invalidate` | 内部密钥 | [详情](./internal/internal.md)(标记某平台 shopId 缓存 deeplink 失效) |
|
||||
| N5 | `POST /internal/launch-confirm-sample` | 内部密钥 | [详情](./internal/internal.md)(启动确认窗兜底样本落 `launch_confirm_sample`) |
|
||||
| N6 | `POST /internal/app-version` | 内部密钥 | [详情](./internal/internal.md)(发布流程写最新 App 版本,落 `app_config`) |
|
||||
| N7 | `GET /internal/launch-confirm-samples` | 内部密钥 | [详情](./internal/internal.md)(样本列表,供 pricebot distill 脚本聚合沉淀回静态规则,#91) |
|
||||
| **静态资源**(StaticFiles 挂载,见下方 `/media` 静态服务) |||
|
||||
| - | `GET /media/avatars/<file>` | 无 | 用户头像;返回二进制图片 |
|
||||
| - | `GET /media/feedback/<file>` | 无 | 反馈截图;返回二进制图片 |
|
||||
| **运营后台 Admin**(独立子应用 `app/admin/`,前缀 `/admin/api`,独立进程 + 独立 admin JWT。鉴权列:`admin`=任意已登录管理员,`operator`/`finance`/`super_admin`=需对应角色(`super_admin` 恒通过)) |||
|
||||
| A1 | `POST /admin/api/auth/login` | 无 | [详情](./admin/auth/admin-auth-login.md) |
|
||||
| A2 | `GET /admin/api/auth/me` | admin | [详情](./admin/auth/admin-auth-me.md) |
|
||||
| A3 | `GET /admin/api/stats/overview` | admin | [详情](./admin/admin-stats-overview.md) |
|
||||
| A4 | `GET /admin/api/users` | admin | [详情](./admin/users/admin-users-list.md) |
|
||||
| A5 | `GET /admin/api/users/{user_id}` | admin | [详情](./admin/users/admin-user-detail.md) |
|
||||
| A6 | `POST /admin/api/users/{user_id}/status` | operator | [详情](./admin/users/admin-user-status.md) |
|
||||
| A7 | `POST /admin/api/users/{user_id}/coins` | finance | [详情](./admin/users/admin-user-coins.md) |
|
||||
| A8 | `POST /admin/api/users/{user_id}/cash` | finance | [详情](./admin/users/admin-user-cash.md) |
|
||||
| A9 | `GET /admin/api/wallet/coin-transactions` | admin | [详情](./admin/wallet/admin-wallet-coin-transactions.md) |
|
||||
| A10 | `GET /admin/api/wallet/cash-transactions` | admin | [详情](./admin/wallet/admin-wallet-cash-transactions.md) |
|
||||
| A11 | `GET /admin/api/withdraws` | admin | [详情](./admin/withdraws/admin-withdraws-list.md) |
|
||||
| A12 | `POST /admin/api/withdraws/reconcile` | finance | [详情](./admin/withdraws/admin-withdraw-reconcile.md) |
|
||||
| A13 | `POST /admin/api/withdraws/{out_bill_no}/refresh` | finance | [详情](./admin/withdraws/admin-withdraw-refresh.md) |
|
||||
| A14 | `GET /admin/api/feedbacks` | admin | [详情](./admin/feedbacks/admin-feedbacks-list.md) |
|
||||
| A15 | `POST /admin/api/feedbacks/{feedback_id}/handle` | operator | [详情](./admin/feedbacks/admin-feedback-handle.md) |
|
||||
| A16 | `GET /admin/api/admins` | super_admin | [详情](./admin/admins/admin-admins-list.md) |
|
||||
| A17 | `POST /admin/api/admins` | super_admin | [详情](./admin/admins/admin-admin-create.md) |
|
||||
| A18 | `PATCH /admin/api/admins/{admin_id}` | super_admin | [详情](./admin/admins/admin-admin-update.md) |
|
||||
| A19 | `GET /admin/api/audit-logs` | admin | [详情](./admin/admin-audit-logs.md) |
|
||||
| A20 | `GET /admin/api/dashboard-display` | admin | [详情](./admin/admin-dashboard-display.md) |
|
||||
| A21 | `PATCH /admin/api/dashboard-display/{metric}` | operator | [详情](./admin/admin-dashboard-display.md) |
|
||||
| A22 | `GET /admin/api/marquee-seeds` | admin | [详情](./admin/admin-marquee-seeds.md) |
|
||||
| A23 | `POST /admin/api/marquee-seeds` | operator | [详情](./admin/admin-marquee-seeds.md) |
|
||||
| A24 | `PATCH /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin/admin-marquee-seeds.md) |
|
||||
| A25 | `DELETE /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin/admin-marquee-seeds.md) |
|
||||
| A26 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin/admin-marquee-seeds.md) |
|
||||
| A27 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./admin/admin-marquee-seeds.md) |
|
||||
| A28 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin/ad/admin-ad-coin-audit.md)(看广告金币公式复算对账,只读) |
|
||||
| A29 | `GET /admin/api/ad-revenue-report` | admin | [详情](./admin/ad/admin-ad-revenue-report.md)(广告收益报表:按用户/日期/类型/应用/代码位 聚合 条数/收益/金币,只读) |
|
||||
| **运营后台 Admin**(独立子应用 `app/admin/`,前缀 `/admin/api`,独立进程 + 独立 admin JWT。鉴权列:`admin`=任意已登录管理员,`operator`/`finance`/`super_admin`=需对应角色;#117 起可见页由 [admin_role](../database/admin_role.md) 数据驱动,`super_admin` 恒通过) |||
|
||||
| A1 | `POST /admin/api/auth/login` · `GET /auth/me` | 无 / admin | [详情](./admin/auth/admin-auth-login.md) / [me](./admin/auth/admin-auth-me.md)(me 返回有效可见页 `pages`) |
|
||||
| A2 | `GET /admin/api/stats/overview` | admin | [详情](./admin/admin-stats-overview.md)(大盘核心指标;#103 按 trace 聚合 + 京东收益 #90 + feed_scene 口径 #125) |
|
||||
| A3 | `GET /admin/api/event-logs` | admin | [详情](./admin/admin-event-logs.md)(埋点日志检索,#83) |
|
||||
| **A·用户**:`GET /users`(筛选排序分页)、`GET /users/{id}`(360 详情)、`GET /{id}/reward-stats` + `GET /{id}/coin-records`(提现详情联查)、`POST /{id}/status`(封禁)、`POST /{id}/debug-trace`(调试链接权限)、`POST /{id}/coins`、`POST /{id}/cash`(#95 `account` 目标账户) ||| [列表](./admin/users/admin-users-list.md) / [详情](./admin/users/admin-user-detail.md) / [状态+debug-trace](./admin/users/admin-user-status.md) / [金币](./admin/users/admin-user-coins.md) / [现金](./admin/users/admin-user-cash.md) |
|
||||
| A4 | `GET /admin/api/wallet/coin-transactions` / `cash-transactions` | admin | [金币](./admin/wallet/admin-wallet-coin-transactions.md) / [现金](./admin/wallet/admin-wallet-cash-transactions.md) |
|
||||
| **A·提现审核台**:`GET /withdraws`(列表)、`/summary`、`/health-check`(finance)、`/ledger-check`(#121 分账对账)、`/{out_bill_no}`(详情)、`POST /reconcile`、单笔 `refresh`/`approve`/`reject`、批量 `bulk/refresh`/`bulk/approve`/`bulk/reject` ||| [列表](./admin/withdraws/admin-withdraws-list.md) / [审核族](./admin/withdraws/admin-withdraw-review.md) / [对账](./admin/withdraws/admin-withdraw-reconcile.md) / [查单](./admin/withdraws/admin-withdraw-refresh.md) |
|
||||
| **A·反馈**:`GET /feedbacks`、`/summary`、`POST /{id}/approve`(采纳发币 #94)、`/{id}/reject`、`/{id}/handle` ||| [列表](./admin/feedbacks/admin-feedbacks-list.md) / [审核族](./admin/feedbacks/admin-feedback-handle.md) |
|
||||
| A5 | `GET`/`PATCH` `/admin/api/feedback-config`,`POST`/`DELETE` `…/image` | operator | 反馈页「加群二维码」卡配置(admin 侧;C 端读见 38a)(无单独文档,见 `app/admin/routers/feedback_qr.py`) |
|
||||
| **A·上报更低价**:`GET /price-reports`、`/summary`、`POST /{id}/approve|reject`(#94) ||| [审核族](./admin/admin-price-reports.md) |
|
||||
| A6 | `GET /admin/api/comparison-records`(+`/{id}` 详情) | admin | 比价记录检索(按 user/phone/**店与商品名模糊搜** #117 筛;详情含 LLM 调用明细)(无单独文档,见 `app/admin/routers/comparison.py`) |
|
||||
| A7 | `GET /admin/api/coupon-data`(+`/user-records`) | admin | [详情](./admin/admin-coupon-data.md)(领券数据看板,#99) |
|
||||
| A8 | `GET /admin/api/device-liveness`(+`/stats`) | admin | [详情](./admin/admin-device-liveness.md)(设备存活监控,#80) |
|
||||
| A9 | `GET /onboarding/devices`、`POST /devices/{id}/reset`、`POST /reset-all` | operator | 新手引导记录管理(按设备聚合/重置)(无单独文档,见 `app/admin/routers/onboarding.py`) |
|
||||
| **A·轮播**:`GET /marquee-seeds`、`/preview`、`/real-records`(#123)、`GET`/`PATCH` `/mode`(#122,模式落 `app_config`)、`POST`(+`/bulk`、`/batch-delete`、`/batch-enable`)、`PATCH`/`DELETE` `/{seed_id}` ||| [详情](./admin/admin-marquee-seeds.md) |
|
||||
| A10 | `GET / PATCH /admin/api/dashboard-display` | admin / operator | [详情](./admin/admin-dashboard-display.md)(首页三统计配置) |
|
||||
| A11 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin/ad/admin-ad-coin-audit.md)(看广告金币公式复算对账,只读) |
|
||||
| A12 | `GET /admin/api/ad-revenue-report` | admin | [详情](./admin/ad/admin-ad-revenue-report.md)(广告收益报表:分页/场景/`app_env` 筛 + **DAU/ARPU** #120;真实收益侧接穿山甲日表 #92) |
|
||||
| A13 | `GET / PATCH /admin/api/ad-config` | operator/finance | 广告配置(穿山甲 ID/验签密钥/各场景开关;C 端只读版见 40b)(无单独文档,见 `app/admin/routers/ad_config.py`) |
|
||||
| A14 | `GET /admin/api/config`、`PATCH /config/{key}` | operator/finance | 运营可配置项([app_config](../database/app_config.md):奖励常量/提现地板价等;#117 修系统配置下发)(无单独文档,见 `app/admin/routers/config.py`) |
|
||||
| **A·管理员与角色**(super_admin):`GET`/`POST` `/admins`、`PATCH`/`DELETE` `/admins/{id}`(#126 删除+`pages_override`)、`GET`/`POST` `/roles`、`GET /roles/catalog`、`PATCH`/`DELETE` `/roles/{id}`(#117/#126 自定义角色) ||| [列表](./admin/admins/admin-admins-list.md) / [建](./admin/admins/admin-admin-create.md) / [改+删](./admin/admins/admin-admin-update.md) / [角色](./admin/admin-roles.md) |
|
||||
| A15 | `GET /admin/api/audit-logs` | admin | [详情](./admin/admin-audit-logs.md) |
|
||||
| **A·CPS 运营台**:群/活动 CRUD、`POST /referral-links`、`POST /orders/reconcile`(美团+京东 #90)、`GET /orders`、`/stats`、群 `timeseries`/`daily`/`wx-users`/`day-users`(#79) ||| [详情](./admin/admin-cps.md) |
|
||||
| - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) |
|
||||
|
||||
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
|
||||
> `coupon/step` 及外卖比价的 `intent/recognize`、`intent/precoupon/step`、`intent/step`、`price/step`、`trace/finalize` 都透传到 pricebot-backend,**MVP 阶段均不鉴权**(device_id 透传,待补 JWT——见 `app/api/v1/compare.py`)。
|
||||
> `coupon/step` 透传到 pricebot-backend,**仍不鉴权**(device_id 区分设备,待补 JWT)。外卖比价透传族(`intent/*`、`price/step`、`trace/finalize|epilogue`)2026-07 起改**软鉴权 OptionalUser**:带 Bearer 则比价记录绑 `user_id`,不带也放行;并由 app-server 首帧签发 `trace_id` + harvest 落 `comparison_record`(见 `app/api/v1/compare.py` 模块注释)。
|
||||
> 福利相关业务接口(wallet/signin/tasks/savings、`ad/reward-status`、`ad/feed-reward`)均需 **Bearer**;`wallet/exchange-info` 是静态规则无鉴权;`ad/pangle-callback` 不走 JWT、靠穿山甲**验签**;`ad/test-grant` **仅本地联调**(开关控制,生产 404)。
|
||||
> 金额字段一律以**分**为单位(`*_cents`)。
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# /admin/api/coupon-data — 领券数据看板(#99)
|
||||
|
||||
> 所属:Admin 子应用(前缀 `/admin/api`) | 鉴权:admin(任意已登录管理员) | 表 [coupon_session](../../database/coupon_session.md) | [← 返回 API 索引](../README.md)
|
||||
|
||||
数据源是客户端两段上报的 `coupon_session`(一次领券任务一行:发起建行/收尾更新)。看板量化:发起数、完成率、**中途流失**(started 无终态)、平均/分位耗时、各平台耗时、机型/ROM 维度。
|
||||
|
||||
## 端点
|
||||
|
||||
| 方法 + 路径 | 说明 |
|
||||
|---|---|
|
||||
| `GET /admin/api/coupon-data` | 看板聚合:发起/完成数 + 耗时分位 + 按天趋势 + 逐条明细(按 `started_date` 区间 + `app_env` 筛,默认只看 prod 防测试数据串台) |
|
||||
| `GET /admin/api/coupon-data/user-records` | 某用户全部领券记录(用户列表点手机号抽屉:领券次数 + 记录列表) |
|
||||
|
||||
## 说明
|
||||
- 明细行 LEFT JOIN `user` 出手机号/昵称(匿名领券行 user 列为空)。
|
||||
- 「发起平台」列按 `origin_package` 区分:空=App 内首页发起,包名=从对应外卖 App 弹券引导发起。
|
||||
- 耗时口径:`elapsed_ms` 客户端全程计时(只统计 completed)。
|
||||
@@ -0,0 +1,28 @@
|
||||
# /admin/api/cps — CPS 群发联盟运营台(群/活动/短链/对账)
|
||||
|
||||
> 所属:Admin 子应用(前缀 `/admin/api/cps`) | 鉴权:读=admin,写=operator/finance(对账) | 表 [cps_group](../../database/cps_group.md) / [cps_activity](../../database/cps_activity.md) / [cps_link](../../database/cps_link.md) / [cps_click](../../database/cps_click.md) / [cps_order](../../database/cps_order.md) / [cps_wx_user](../../database/cps_wx_user.md) | [← 返回 API 索引](../README.md)
|
||||
>
|
||||
> 业务与授权流程详见 [guides/CPS发券分发与微信授权](../../guides/CPS发券分发与微信授权.md);C 端落地短链见 [cps-redirect](../other/cps-redirect.md)。
|
||||
|
||||
私域社群 CPS 的完整运营链:建群(拿 `sid`)→ 建活动(券/物料)→ 生成群发短链 `/c/{code}` → 用户点击/复制口令 → 联盟订单按 `sid` 归群对账,汇成「点击→下单→佣金」漏斗。
|
||||
|
||||
## 端点
|
||||
|
||||
| 方法 + 路径 | 说明 |
|
||||
|---|---|
|
||||
| `GET / POST /admin/api/cps/groups`,`PATCH / DELETE /groups/{id}` | 推广群 CRUD;含美团平台的群自动分配 `sid` |
|
||||
| `GET / POST /admin/api/cps/activities`,`PATCH / DELETE /activities/{id}` | 可推广活动 CRUD(美团 actId / 淘宝淘口令 / 京东链接) |
|
||||
| `POST /admin/api/cps/upload-image`、`GET /activity-images` | 活动落地页图上传 / 已有图列表(新建复用) |
|
||||
| `POST /admin/api/cps/referral-links` | 批量生成群×活动短链(美团经 sid 转链) |
|
||||
| `POST /admin/api/cps/orders/reconcile` | 拉联盟订单对账(美团 `query_order` + 京东联盟 #90,`order_id` 幂等 upsert;finance) |
|
||||
| `GET /admin/api/cps/orders` | 订单明细(游标分页,可按 sid / 状态筛) |
|
||||
| `GET /admin/api/cps/stats` | 按群对账统计(点击/订单/GMV/预估与结算佣金) |
|
||||
| `GET /admin/api/cps/groups/{id}/timeseries` | 群点击时序(天/小时级 PV/UV/复制,折线图) |
|
||||
| `GET /admin/api/cps/groups/{id}/daily` | 群每天明细大表格(点击+订单按天合并;#79 起支持按天下钻) |
|
||||
| `GET /admin/api/cps/groups/{id}/wx-users` | 群内微信用户(领券画像:头像/昵称/领券次数) |
|
||||
| `GET /admin/api/cps/groups/{id}/day-users` | 某天该群按用户的领券/点击 + 每人点过的券(#79) |
|
||||
|
||||
## 说明
|
||||
- 订单与点击**只能在群(sid)维度汇合**,无法对到单笔(联盟只回传 sid)。
|
||||
- 京东单有效性按 `jd_valid_code`,美团按 `mt_status`(4 取消/5 风控不计佣,6 结算才到账);淘宝无对账 API,对账列显示 `-`。
|
||||
- #119 修美团 `pay_time` 入库为空导致大盘时间窗漏算。
|
||||
@@ -0,0 +1,15 @@
|
||||
# /admin/api/device-liveness — 设备存活监控(#80)
|
||||
|
||||
> 所属:Admin 子应用(前缀 `/admin/api`) | 鉴权:admin | 表 [device_liveness](../../database/device_liveness.md) | [← 返回 API 索引](../README.md)
|
||||
|
||||
无障碍保护存活的后台视角:哪些设备开过保护(`ever_protected`)、现在在线还是掉线(心跳超时,#107 起阈值 1 小时)、首次开启时间(`first_protected_at`)。
|
||||
|
||||
## 端点
|
||||
|
||||
| 方法 + 路径 | 说明 |
|
||||
|---|---|
|
||||
| `GET /admin/api/device-liveness/stats` | 顶部卡片统计:设备总数 / 开过保护 / 当前在线 / 掉线数 |
|
||||
| `GET /admin/api/device-liveness` | 设备存活列表(游标分页):在线情况/设备 id/归属用户 筛选 + 排序,**默认掉线置顶**;行含最近心跳、首次开启、App 版本、push token 有无 |
|
||||
|
||||
## 说明
|
||||
- 「在线」= `last_heartbeat_at` 距今 < 超时阈值;掉线召回链路(worker 置 `kill_alert_pending` → 客户端 pull)见表文档。
|
||||
@@ -0,0 +1,15 @@
|
||||
# /admin/api/event-logs — 埋点日志(#83)
|
||||
|
||||
> 所属:Admin 子应用(前缀 `/admin/api`) | 鉴权:admin | 表 [analytics_event](../../database/analytics_event.md) | [← 返回 API 索引](../README.md)
|
||||
|
||||
客户端埋点(`POST /api/v1/analytics/events` 批量上报)的后台检索页。
|
||||
|
||||
## 端点
|
||||
|
||||
| 方法 + 路径 | 说明 |
|
||||
|---|---|
|
||||
| `GET /admin/api/event-logs` | 埋点事件列表(游标分页):可按 `event` / `device_id` / `user_id` 筛;行含事件名、props、页面、机型/系统/网络、client_ts |
|
||||
|
||||
## 说明
|
||||
- 纯只读;无聚合报表(要分析导出后自己算)。
|
||||
- 时间轴用 `client_ts`(事件真实发生时刻),入库时间受客户端攒批影响。
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 所属:Admin 组(前缀 `/admin/api/marquee-seeds`) | 鉴权:Admin Bearer(改需 operator/super) | [← 返回 API 索引](../README.md)
|
||||
|
||||
管理首页轮播「真实+种子混播」的兜底种子。种子是「生成规则」:`masked_user` 可空(空→feed 随机合成名)、金额是 `[min_cents, max_cents]` 区间(feed 每次随机取值)。用户侧 feed 见 [platform-savings-feed](./platform-savings-feed.md);表见 [ops_marquee_seed](../database/ops_marquee_seed.md)。金额单位:分(前端 ÷100 显示元)。
|
||||
管理首页轮播「真实+种子混播」的兜底种子。种子是「生成规则」:`masked_user` 可空(空→feed 随机合成名)、金额是 `[min_cents, max_cents]` 区间(feed 每次随机取值)。用户侧 feed 见 [platform-savings-feed](../savings/platform-savings-feed.md);表见 [ops_marquee_seed](../../database/ops_marquee_seed.md)。金额单位:分(前端 ÷100 显示元)。
|
||||
|
||||
## 复用结构 OpsMarqueeSeedOut
|
||||
| 字段 | 类型 | 说明 |
|
||||
@@ -19,9 +19,20 @@
|
||||
出参 `200`:`list[OpsMarqueeSeedOut]`(按 `sort_order,id`)。
|
||||
|
||||
## GET /admin/api/marquee-seeds/preview — 预览实际混播 feed
|
||||
预览客户端实际会看到的轮播(真实记录会插队、种子随机抽取 / 金额随机 / 名字合成),供运营对效果。**含随机,每次结果不同**。
|
||||
预览客户端实际会看到的轮播(真实记录会插队、种子随机抽取 / 金额随机 / 名字合成),供运营对效果。**含随机,每次结果不同**;#122 起按**当前数据源模式**实时预览(mixed/real/seed 各自的真实产出)。
|
||||
- 入参:`limit`(query,1~30,默认 8)
|
||||
- 出参 `200`:`{"items": [{masked_user, saved_amount_cents, time}]}`(条目同 [platform-savings-feed](./platform-savings-feed.md))
|
||||
- 出参 `200`:`{"items": [{masked_user, saved_amount_cents, time}]}`(条目同 [platform-savings-feed](../savings/platform-savings-feed.md))
|
||||
|
||||
## GET /admin/api/marquee-seeds/real-records — 分页浏览当前模式下可展示的真实记录(#123)
|
||||
审核用:看「真实条」到底会拿哪些 `comparison_record` 上轮播(真实条**不按用户去重**,打乱+去连簇后混播;默认昵称归「无昵称」脱敏档,#122)。
|
||||
- 入参:`limit` / `cursor`(游标分页)
|
||||
- 出参 `200`:`{"items": [...], "next_cursor": int|null}`
|
||||
|
||||
## GET /admin/api/marquee-seeds/mode — 首页轮播数据源模式
|
||||
出参:`{"mode": "mixed" | "real" | "seed"}`(混播 / 只真实 / 只种子)。
|
||||
|
||||
## PATCH /admin/api/marquee-seeds/mode — 改数据源模式(带审计)
|
||||
- 入参:`{"mode": "mixed" | "real" | "seed"}`;operator 起。改动客户端重拉 feed 生效。
|
||||
|
||||
## POST /admin/api/marquee-seeds — 新增(带审计)
|
||||
入参 `OpsMarqueeSeedCreate`:`masked_user`(可选,空 / 不传 → 随机合成)、`min_cents`(必填,≥0)、`max_cents`(必填,≥min,≤1000 元)、`enabled`(默认 true)、`sort_order`(默认 0)。出参:新建的 `OpsMarqueeSeedOut`。`400`=金额非法。
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# /admin/api/price-reports — 上报更低价审核(#94)
|
||||
|
||||
> 所属:Admin 子应用(前缀 `/admin/api/price-reports`) | 鉴权:读=admin,审=operator | 表 [price_report](../../database/price_report.md) | [← 返回 API 索引](../README.md)
|
||||
>
|
||||
> C 端提交/查询见 [report-submit](../other/report-submit.md) / [report-records](../other/report-records.md)。
|
||||
|
||||
用户众包「上报更低价」的人工审核台:审截图与价格,通过发固定金币。
|
||||
|
||||
## 端点
|
||||
|
||||
| 方法 + 路径 | 说明 |
|
||||
|---|---|
|
||||
| `GET /admin/api/price-reports` | 上报列表(状态筛选 + 游标分页,含截图、关联比价记录快照) |
|
||||
| `GET /admin/api/price-reports/summary` | 审核统计(pending/approved/rejected 计数) |
|
||||
| `POST /admin/api/price-reports/{report_id}/approve` | 通过 → 发固定金币(`grant_coins` 同事务)+ 带审计 |
|
||||
| `POST /admin/api/price-reports/{report_id}/reject` | 拒绝(填原因,用户端可见)+ 带审计 |
|
||||
@@ -0,0 +1,19 @@
|
||||
# /admin/api/roles — 角色与可见页管理(RBAC,#117/#126)
|
||||
|
||||
> 所属:Admin 子应用(前缀 `/admin/api`,独立 admin JWT) | 鉴权:**super_admin** | 表 [admin_role](../../database/admin_role.md) | [← 返回 API 索引](../README.md)
|
||||
|
||||
后台 RBAC 的数据驱动层:内建三角色(`super_admin`/`finance`/`operator`,`is_builtin=true` 不可删)+ **自定义角色**(勾任意页面组合)。管理员的有效可见页 = `admin_user.pages_override`(个人覆盖,非空优先)∪ 否则取其角色 `pages`;`super_admin` 恒全通。
|
||||
|
||||
## 端点
|
||||
|
||||
| 方法 + 路径 | 说明 |
|
||||
|---|---|
|
||||
| `GET /admin/api/roles` | 角色列表:`[{id, name, label, pages, is_builtin, admin_count}]`(含每个角色的使用人数) |
|
||||
| `GET /admin/api/roles/catalog` | 页面权限目录(分组):全部可勾选的页面 key(来自 `app/admin/permissions.py` 常量,不落库),前端渲染勾选面板用 |
|
||||
| `POST /admin/api/roles` | 新增自定义角色 `{name, pages}`(**name(key)= label = 输入名称**;不能叫 `super_admin`,重名 `409`);带审计 |
|
||||
| `PATCH /admin/api/roles/{role_id}` | 改展示名/可见页(内建角色的 `pages` 也可调);带审计 |
|
||||
| `DELETE /admin/api/roles/{role_id}` | 删角色;**内建(`is_builtin`)与在用(有 `admin_user.role` 引用)不可删**(400);带审计 |
|
||||
|
||||
## 说明
|
||||
- 管理员个人覆盖在 [`PATCH /admin/api/admins/{id}`](./admins/admin-admin-update.md) 的 `pages_override` 字段改,不在本组。
|
||||
- 加新后台页面要同步登记 `permissions.py` 目录,表里只存勾选结果(目录变更无需迁移)。
|
||||
@@ -8,12 +8,13 @@
|
||||
|---|---|---|---|
|
||||
| `admin_id` | int | ✓ | 目标管理员 id |
|
||||
|
||||
**application/json**(三字段都可选,只改传了的;至少传一个):
|
||||
**application/json**(字段都可选,只改传了的;至少传一个):
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `role` | string | ✗ | 改角色,枚举:`super_admin` / `finance` / `operator` |
|
||||
| `role` | string | ✗ | 改角色:内建 `super_admin` / `finance` / `operator` **或自定义角色 name**(#117/#126,见 [admin-roles](../admin-roles.md)) |
|
||||
| `pages_override` | list[string] \| null | ✗ | 个人可见页覆盖(#126):非空优先于角色 pages;传 `null` 清覆盖回归角色 |
|
||||
| `status` | string | ✗ | 启停,枚举:`active`(启用)/ `disabled`(禁用) |
|
||||
| `password` | string | ✗ | 重置密码,8–72 字(传则覆盖原密码) |
|
||||
| `password` | string | ✗ | 重置密码,8–72 字(传则覆盖原密码,同时更新 `plain_password` 明文副本) |
|
||||
|
||||
## 出参
|
||||
响应 `200`:`AdminOut`(更新后的管理员)
|
||||
@@ -24,9 +25,15 @@
|
||||
| `username` | string | 账号 |
|
||||
| `role` | string | 角色 |
|
||||
| `status` | string | 状态 |
|
||||
| `pages` | list[string] | **有效可见页**(pages_override 优先,否则角色 pages;super_admin 全量) |
|
||||
| `created_at` | datetime | 创建时间(UTC) |
|
||||
| `last_login_at` | datetime \| null | 上次登录时间 |
|
||||
|
||||
---
|
||||
|
||||
## DELETE /admin/api/admins/{admin_id} — 删除管理员(#126)
|
||||
物理删除(区别于禁用);**不可删自己**(`400`);带审计(`action=admin.delete`)。鉴权同本组(super_admin)。出参 `{"deleted": true}`。
|
||||
|
||||
## 错误码
|
||||
- `400` 不能禁用自己(`admin_id == 当前 admin.id` 且 `status=disabled`) / 无任何变更字段(三字段全空)
|
||||
- `401` 未带 admin token / token 无效或过期 / 管理员被禁用
|
||||
@@ -35,5 +42,5 @@
|
||||
- `422` `role`/`status` 非法枚举 / `password` 长度不在 8–72
|
||||
|
||||
## 说明
|
||||
- 更新成功后写一条审计:`action=admin.update`、`target_type=admin`、`target_id=admin_id`、`detail` 为本次实际变更字段(如 `{"role": "...", "status": "...", "password": "reset"}`,密码只记 `reset` 不记明文)。见 [admin_audit_log](../database/admin_audit_log.md)。
|
||||
- 数据表见 [admin_user](../database/admin_user.md)。
|
||||
- 更新成功后写一条审计:`action=admin.update`、`target_type=admin`、`target_id=admin_id`、`detail` 为本次实际变更字段(如 `{"role": "...", "status": "...", "password": "reset"}`,密码只记 `reset` 不记明文)。见 [admin_audit_log](../../../database/admin_audit_log.md)。
|
||||
- 数据表见 [admin_user](../../../database/admin_user.md)。
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
# POST /admin/api/feedbacks/{feedback_id}/handle — 标记反馈已处理
|
||||
# /admin/api/feedbacks — 反馈审核族(采纳/拒绝/标记处理/统计)
|
||||
|
||||
> 所属:Admin·反馈 组(前缀 `/admin/api/feedbacks`) | 鉴权:Bearer admin_token(角色:`operator`,`super_admin` 恒通过,`require_role("operator")`) | [← 返回 API 索引](../../README.md)
|
||||
> 所属:Admin·反馈 组(前缀 `/admin/api/feedbacks`) | 鉴权:Bearer admin_token(角色:`operator`,`super_admin` 恒通过,`require_role("operator")`;summary 任意 admin) | [← 返回 API 索引](../../README.md)
|
||||
>
|
||||
> 列表见 [admin-feedbacks-list](./admin-feedbacks-list.md)。#94 引入 采纳(发金币)/拒绝 审核语义,#105 加运营回复 `admin_reply`;旧「标记已处理」保留。
|
||||
|
||||
## POST /admin/api/feedbacks/{feedback_id}/approve — 采纳并发金币(#94)
|
||||
- body:`{reward_coins?(int,可 0), admin_reply?(string,用户可见), review_note?(string,内部)}`
|
||||
- 行为:`status → adopted`;`reward_coins > 0` 时走 `grant_coins` 同事务发币(流水 `coin_transaction`);写审计(`detail.after="adopted"`)。已终态(非 pending/new)→ `400`。
|
||||
- 出参:更新后的 FeedbackOut。
|
||||
|
||||
## POST /admin/api/feedbacks/{feedback_id}/reject — 拒绝采纳(#94)
|
||||
- body:`{reject_reason(string,用户可见), admin_reply?, review_note?}`
|
||||
- 行为:`status → rejected`;写审计(`detail.after="rejected"`)。已终态 → `400`。
|
||||
|
||||
## GET /admin/api/feedbacks/summary — 审核统计
|
||||
- 出参:各状态计数(`pending` / `adopted` / `rejected` / `handled`),审核台顶部卡片用;任意 admin 可读。
|
||||
|
||||
---
|
||||
|
||||
## POST /admin/api/feedbacks/{feedback_id}/handle — 标记反馈已处理(旧口径)
|
||||
|
||||
## 入参
|
||||
- 路径:`feedback_id`(int)
|
||||
@@ -18,6 +36,6 @@
|
||||
- `422` `feedback_id` 非合法 int
|
||||
|
||||
## 说明
|
||||
- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action="feedback.handle"`、`target_type="feedback"`、`target_id=<feedback_id>`、`detail={"before": <原 status>, "after": "handled"}`、`ip=<客户端 IP>`。
|
||||
- 写操作记审计 [admin_audit_log](../../../database/admin_audit_log.md):`action="feedback.handle"`、`target_type="feedback"`、`target_id=<feedback_id>`、`detail={"before": <原 status>, "after": "handled"}`、`ip=<客户端 IP>`。
|
||||
- 状态变更与审计写入在同一事务(`commit=False` 后统一 `db.commit()`)。
|
||||
- 关联表 [feedback](../database/feedback.md)。
|
||||
- 关联表 [feedback](../../../database/feedback.md)。
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `mode` | string | ✗ | `delta`(默认)=增减 / `set`=设为指定值 |
|
||||
| `account` | string | ✗ | 目标账户(#95):`coin_cash`(默认,金币兑换的现金)/ `invite_cash`(邀请奖励金)。两本账物理隔离、各调各 |
|
||||
| `amount_cents` | int | ✓ | `delta` 模式:现金变动(分,正=发放,负=扣减,不可为 0);`set` 模式:目标现金值(分,须 ≥ 0) |
|
||||
| `reason` | string | ✓ | 操作原因,1–128 字(必填,入审计与流水备注) |
|
||||
|
||||
@@ -28,7 +29,7 @@
|
||||
- 金额单位一律为**分**(`*_cents`);本接口只动现金余额,不涉及金币。
|
||||
- **set 模式**:读当前余额算出差值 `delta = target - 当前余额`,再复用同一套写入逻辑(故只写一笔差值流水)。目标值须 ≥ 0;差值为 0(已等于目标)直接拒绝。
|
||||
- 扣减保护:实际写入的 `delta < 0` 时若扣减后现金余额 < 0 直接拒绝(运营误操作保护);set 模式目标值 ≥ 0 天然不会扣成负。
|
||||
- 现金变动写流水 [cash_transaction](../database/cash_transaction.md):`biz_type` 实际差值为正记 `admin_grant`、为负记 `admin_deduct`(set 模式同理,不新增流水类型),`remark = admin:<reason>`(截断至 128 字)。
|
||||
- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action = user.cash.grant`,`target_type = user`,`target_id = user_id`,`detail = {amount_cents(=实际差值), balance_after_cents, reason}`;set 模式额外带 `{mode:"set", target_cents, before_cents}`。并记录操作 IP。
|
||||
- 现金变动按 `account` 写对应账本流水:`coin_cash` → [cash_transaction](../../../database/cash_transaction.md),`invite_cash` → [invite_cash_transaction](../../../database/invite_cash_transaction.md)(#95);`biz_type` 实际差值为正记 `admin_grant`、为负记 `admin_deduct`(set 模式同理,不新增流水类型),`remark = admin:<reason>`(截断至 128 字)。
|
||||
- 写操作记审计 [admin_audit_log](../../../database/admin_audit_log.md):`action = user.cash.grant`,`target_type = user`,`target_id = user_id`,`detail = {amount_cents(=实际差值), balance_after_cents, reason}`;set 模式额外带 `{mode:"set", target_cents, before_cents}`。并记录操作 IP。
|
||||
- 现金变动 + 审计在同一事务原子提交(改钱必留痕)。
|
||||
- 关联用户表 [user](../database/user.md);现金账户 [coin_account](../database/coin_account.md);现金流水 [cash_transaction](../database/cash_transaction.md)。
|
||||
- 关联用户表 [user](../../../database/user.md);现金账户 [coin_account](../../../database/coin_account.md);现金流水 [cash_transaction](../../../database/cash_transaction.md)。
|
||||
|
||||
@@ -38,6 +38,15 @@
|
||||
- `422` `user_id` 非整数
|
||||
|
||||
## 说明
|
||||
- 金币三项(`coin_balance` / `cash_balance_cents` / `total_coin_earned`)读 [coin_account](../database/coin_account.md);从未发生金币动作(账户不存在)时统一返回 0。
|
||||
- 金币三项(`coin_balance` / `cash_balance_cents` / `total_coin_earned`)读 [coin_account](../../../database/coin_account.md);从未发生金币动作(账户不存在)时统一返回 0。
|
||||
- 各 count 为聚合数,明细历史走带 `user_id` 过滤的分页接口(金币流水 / 现金流水 / 提现 / 比价 / 反馈)。
|
||||
- 关联用户表 [user](../database/user.md);金币账户 [coin_account](../database/coin_account.md);提现单 [withdraw_order](../database/withdraw_order.md);比价记录 [comparison_record](../database/comparison_record.md);反馈 [feedback](../database/feedback.md)。
|
||||
- 关联用户表 [user](../../../database/user.md);金币账户 [coin_account](../../../database/coin_account.md);提现单 [withdraw_order](../../../database/withdraw_order.md);比价记录 [comparison_record](../../../database/comparison_record.md);反馈 [feedback](../../../database/feedback.md)。
|
||||
|
||||
---
|
||||
|
||||
## 族内配套端点(提现详情页联查用)
|
||||
|
||||
| 方法 + 路径 | 说明 |
|
||||
|---|---|
|
||||
| `GET /admin/api/users/{user_id}/reward-stats` | 用户提现/看广告统计(按时间窗口):提现审核时评估该用户金币来源是否健康 |
|
||||
| `GET /admin/api/users/{user_id}/coin-records` | 用户金币发放记录(按时间窗口分页):提现详情底部表,逐笔看发币来源 |
|
||||
|
||||
@@ -21,5 +21,11 @@
|
||||
|
||||
## 说明
|
||||
- 业务写(改用户状态)与审计写在同一事务原子提交:改了就有痕、有痕就真改了。
|
||||
- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action = user.status.set`,`target_type = user`,`target_id = user_id`,`detail = {before, after}`,并记录操作 IP。
|
||||
- 关联用户表 [user](../database/user.md)。
|
||||
- 写操作记审计 [admin_audit_log](../../../database/admin_audit_log.md):`action = user.status.set`,`target_type = user`,`target_id = user_id`,`detail = {before, after}`,并记录操作 IP。
|
||||
- 关联用户表 [user](../../../database/user.md)。
|
||||
|
||||
---
|
||||
|
||||
## POST /admin/api/users/{user_id}/debug-trace — 开关调试链接权限
|
||||
- body:`{"enabled": true|false}` → 写 `user.debug_trace_enabled`(带审计 `action=user.debug_trace.set`)。
|
||||
- 开了的用户在比价完成弹窗 + 比价记录页可见「复制调试链接」按钮(trace_url);运营按用户灰度排障用。鉴权同本组(operator)。
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# /admin/api/withdraws — 提现审核台(审核/批量/对账族)
|
||||
|
||||
> 所属:Admin 子应用(前缀 `/admin/api/withdraws`) | 鉴权:读=admin,写=**finance** | 表 [withdraw_order](../../../database/withdraw_order.md) | [← 返回 API 索引](../../README.md)
|
||||
>
|
||||
> 列表见 [admin-withdraws-list](./admin-withdraws-list.md);单笔查单见 [admin-withdraw-refresh](./admin-withdraw-refresh.md);超时对账见 [admin-withdraw-reconcile](./admin-withdraw-reconcile.md)。本文覆盖其余审核台端点。
|
||||
|
||||
提现状态机:`reviewing`(发起即扣款待审)→ 通过 `pending`(微信转账在途)→ `success`/`failed`(失败退款);拒绝 `rejected`(退款)。**#121 起按 `withdraw_order.source` 分账**:退款/流水落 `cash_transaction`(coin_cash)或 `invite_cash_transaction`(invite_cash)。
|
||||
|
||||
## 端点
|
||||
|
||||
| 方法 + 路径 | 鉴权 | 说明 |
|
||||
|---|---|---|
|
||||
| `GET /admin/api/withdraws/summary` | admin | 审核台统计:各状态计数(待审/在途/成功/失败/拒绝) |
|
||||
| `GET /admin/api/withdraws/health-check` | finance | 提现配置健康检查(证书/密钥路径/商户配置就位与否;暴露路径故限 finance+super) |
|
||||
| `GET /admin/api/withdraws/ledger-check` | admin | **资金账本校验**(#121):按 `source` 分账核对「提现单 ↔ 流水」金额闭环,邀请奖励金提现纳入对账 |
|
||||
| `GET /admin/api/withdraws/{out_bill_no}` | admin | 提现单详情(审核台抽屉;用户维度联查另走 `users/{id}/reward-stats` + `coin-records`) |
|
||||
| `POST /admin/api/withdraws/{out_bill_no}/approve` | finance | 审核通过 → 发起微信打款(`reviewing`→`pending`→查单归一化);带审计 |
|
||||
| `POST /admin/api/withdraws/{out_bill_no}/reject` | finance | 审核拒绝 → 按 source 退款 + `rejected`;带审计 |
|
||||
| `POST /admin/api/withdraws/bulk/refresh` | finance | 批量刷新查单(勾选多笔) |
|
||||
| `POST /admin/api/withdraws/bulk/approve` | finance | 批量审核通过并打款 |
|
||||
| `POST /admin/api/withdraws/bulk/reject` | finance | 批量审核拒绝并退款 |
|
||||
|
||||
## 说明
|
||||
- 批量接口逐单处理、逐单落审计,单笔失败不中断整批(返回逐单结果)。
|
||||
- 结果不明时先查单再定夺、绝不盲目退款(防退款后又到账),同 C 端口径。
|
||||
@@ -1,13 +1,13 @@
|
||||
# POST /api/v1/intent/recognize — 外卖比价 Phase 1 意图识别(透传到 pricebot)
|
||||
# POST /api/v1/intent/recognize — 外卖比价 Phase 1 意图识别(透传 + 首帧 harvest 建行)
|
||||
|
||||
> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**无(MVP 阶段不鉴权)** | [← 返回 API 索引](../README.md)
|
||||
> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**软鉴权 OptionalUser**(带 JWT 则绑 `user_id`,不带也放行) | [← 返回 API 索引](../README.md)
|
||||
|
||||
## 入参
|
||||
任意 JSON body,**不做 schema 校验**,原样透传给上游。后端仅从中读 `device_id`、`trace_id`、`step` 用于日志。
|
||||
任意 JSON body,**不做 schema 校验**,原样透传给上游。后端从中读 `device_id`、`trace_id`、`step`、`device_info` 用于日志与落库。
|
||||
客户端实际传源平台购物车页的无障碍树采集结果(pricebot 协议里的 `screens`:`cart_page_1` / `cart_page_2`)。
|
||||
|
||||
## 出参
|
||||
pricebot-backend 的响应**原样返回**(JSON object)。典型含 `result`(店名)、`calibration`(含 `source_platform_id` / `items` / `price`),客户端在 `step=0` 把它透传进 `/price/step`。
|
||||
pricebot-backend 的响应**原样返回**(JSON object),并在顶层补 `trace_id`。典型含 `result`(店名)、`calibration`(含 `source_platform_id` / `items` / `price`),客户端在 `step=0` 把它透传进 `/price/step`。
|
||||
|
||||
## 错误码
|
||||
- `400` body 不是合法 JSON
|
||||
@@ -18,7 +18,7 @@ pricebot-backend 的响应**原样返回**(JSON object)。典型含 `result`
|
||||
|
||||
外卖比价由客户端无障碍引擎在源平台(淘宝闪购 / 美团 / 京东外卖)购物车页点悬浮球触发 → 调本接口拿 `query` + `calibration` → 进入 `/price/step` 循环。
|
||||
|
||||
⚠️ **MVP 阶段不鉴权**(同 `coupon/step`):`device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 行为暂绑不到登录用户。待补 JWT,见 [待办与技术债.md](../guides/待办与技术债.md) P1。
|
||||
**trace_id 签发 + harvest 建行(2026-07 起,`compare.py`)**:客户端首帧可不带 `trace_id`——app-server 用 uuid 签发、注入转发 body、回填响应顶层;**仅签发那帧**按 `trace_id` 建 [comparison_record](../../database/comparison_record.md) 的 `running` 行(幂等,best-effort),done / finalize 帧再补终态。**软鉴权**:带 Bearer 则记录绑 `user_id`,匿名行 `user_id` 暂空、由后续 `/compare/record` 上报补。
|
||||
|
||||
**相关配置**:
|
||||
- `PRICEBOT_BASE_URL`(默认 `http://localhost:8000`)
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
# POST /api/v1/price/step — 外卖比价 Phase 2 步进(透传到 pricebot)
|
||||
# POST /api/v1/price/step — 外卖比价 Phase 2 步进(透传 + done 帧 harvest 落库)
|
||||
|
||||
> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**无(MVP 阶段不鉴权)** | [← 返回 API 索引](../README.md)
|
||||
> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**软鉴权 OptionalUser**(带 JWT 则绑 `user_id`,不带也放行) | [← 返回 API 索引](../README.md)
|
||||
|
||||
## 入参
|
||||
任意 JSON body,**不做 schema 校验**,原样透传给上游。后端仅从中读 `device_id`、`trace_id`、`step` 用于日志。
|
||||
任意 JSON body,**不做 schema 校验**,原样透传给上游。后端从中读 `device_id`、`trace_id`、`step`、`device_info` 用于日志与落库。
|
||||
客户端逐帧上报 `screen_state` + 上一步 `action_result`;`step=0` 还带 `query` + `calibration`(来自 Phase 1)。
|
||||
|
||||
## 出参
|
||||
pricebot-backend 的响应**原样返回**(JSON object)。含 `action`(tap / set_text / launch / wait / done…)、`continue`、`status`;最终 `done` 帧带 `comparison_results`(源 + 各目标平台到手价,按价升序)。
|
||||
pricebot-backend 的响应**原样返回**(JSON object),并在顶层补 `trace_id`(见下「trace_id 签发」)。含 `action`(tap / set_text / launch / wait / done…)、`continue`、`status`、每帧顶层 `trace_url`;最终 `done` 帧带 `comparison_results`(源 + 各目标平台到手价,按价升序)。
|
||||
|
||||
## 错误码
|
||||
- `400` body 不是合法 JSON
|
||||
- `502` pricebot 上游不可达(网络错误)或返回 5xx
|
||||
|
||||
## 说明
|
||||
把请求体原样转发到 `PRICEBOT_BASE_URL` 的 `/api/price/step`(去掉 `/v1`,async httpx)。**多轮循环**:客户端按返回的 `action` 操作手机、再上报下一帧,直到 `continue=false`。真正的目标驱动比价逻辑(多目标平台串行复现订单、读到手价、聚合排序)在 **pricebot-backend**,本接口只是"透传壳"。
|
||||
把请求体原样转发到 `PRICEBOT_BASE_URL` 的 `/api/price/step`(去掉 `/v1`,共享 httpx 单例)。**多轮循环**:客户端按返回的 `action` 操作手机、再上报下一帧,直到 `continue=false`。真正的目标驱动比价逻辑(多目标平台串行复现订单、读到手价、聚合排序)在 **pricebot-backend**。
|
||||
|
||||
⚠️ **MVP 阶段不鉴权**(同 `coupon/step`)。
|
||||
**不再是纯透传壳(2026-07 起,`compare.py`)**:
|
||||
- **trace_id 签发**:客户端首帧可不带 `trace_id`——app-server 用 uuid 签发、注入转发 body、回填进响应顶层 `trace_id`,客户端后续帧都带它(老客户端自带则原样用)。
|
||||
- **harvest 落库**([comparison_record](../../database/comparison_record.md)):首帧(mint 时)建 `running` 行 → **done 帧** `harvest_done` 写 `success`/`failed` + 派生 `best_*`/`saved_amount_cents`。写库 best-effort(threadpool 独立 session),失败不连累透传。
|
||||
- **软鉴权**:新客户端带 Bearer → 记录绑 `user_id`;老客户端/匿名 → `user_id` 暂空,由其后续 `POST /compare/record` 上报补(灰度期两条写路径按 `trace_id` reconcile,success 不降级)。
|
||||
- 邀请发奖**不在这里**:#113 起口径为好友「比价并下单」,发放在 `POST /order/report`。
|
||||
|
||||
**相关配置**:
|
||||
- `PRICEBOT_BASE_URL`(默认 `http://localhost:8000`;生产部署应与 pricebot-backend 同内网——比价一单 30~80 步、逐帧多一跳,走公网延迟会累积)
|
||||
|
||||
@@ -14,12 +14,13 @@
|
||||
|
||||
| 方法 + 路径 | 落库 | 说明 |
|
||||
|---|---|---|
|
||||
| `POST /internal/price-observation` | [`price_observation`](../database/price_observation.md) | 比价 done 帧整批价格事实上报;`(trace_id, platform, scope)` 幂等,返回 `{inserted, skipped}` |
|
||||
| `GET /internal/store-mapping/lookup` | (只读 [`store_mapping`](../database/store_mapping.md)) | 比价前按 `source_platform`+`name`(+`lat`/`lng`)反查各目标平台已沉淀的店铺 id/deeplink;命中→pricebot 直接 deeplink 省现场搜店 |
|
||||
| `POST /internal/price-observation` | [`price_observation`](../../database/price_observation.md) | 比价 done 帧整批价格事实上报;`(trace_id, platform, scope)` 幂等,返回 `{inserted, skipped}` |
|
||||
| `GET /internal/store-mapping/lookup` | (只读 [`store_mapping`](../../database/store_mapping.md)) | 比价前按 `source_platform`+`name`(+`lat`/`lng`)反查各目标平台已沉淀的店铺 id/deeplink;命中→pricebot 直接 deeplink 省现场搜店 |
|
||||
| `POST /internal/store-mapping` | `store_mapping` | 跨平台「同一家店」身份映射上报;`trace_id` 幂等、填空合并,返回 `{inserted, row_id}` |
|
||||
| `POST /internal/store-mapping/invalidate` | `store_mapping` | 标记某平台 `shop_id` 的缓存 deeplink 失效(pricebot 撞错误页回退时报);当前支持 `taobao`/`jd`,其它平台 no-op,返回 `{ok, affected}` |
|
||||
| `POST /internal/launch-confirm-sample` | [`launch_confirm_sample`](../database/launch_confirm_sample.md) | 启动确认窗 LLM 兜底放行后回写样本(host 包 + 弹窗树 + plan + locale);**都上报、不去重**,返回 `{id}` |
|
||||
| `POST /internal/app-version` | [`app_config`](../database/app_config.md)(key=`latest_app_version`) | **发布流程**(非 pricebot)出 APK 后写最新版本号/下载链接/sha256;客户端再 `GET /api/v1/platform/app-version` 读做 OTA。也是应急改版本信息(紧急下线/改 `apk_url`)入口 |
|
||||
| `POST /internal/launch-confirm-sample` | [`launch_confirm_sample`](../../database/launch_confirm_sample.md) | 启动确认窗 LLM 兜底放行后回写样本(host 包 + 弹窗树 + plan + locale);**都上报、不去重**,返回 `{id}` |
|
||||
| `GET /internal/launch-confirm-samples` | (只读 `launch_confirm_sample`) | 样本列表(#91,供 pricebot `distill_launch_confirm.py` 聚合沉淀回静态规则);可选筛 `exec_success` / `host_package` / `since_days`,`limit` 默认 1000 |
|
||||
| `POST /internal/app-version` | [`app_config`](../../database/app_config.md)(key=`latest_app_version`) | **发布流程**(非 pricebot)出 APK 后写最新版本号/下载链接/sha256;客户端再 `GET /api/v1/platform/app-version` 读做 OTA。也是应急改版本信息(紧急下线/改 `apk_url`)入口 |
|
||||
|
||||
## 错误
|
||||
- `401` 密钥不匹配 / 缺失。
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## POST /bind — 绑定邀请人
|
||||
|
||||
把当前登录用户(被邀请人)绑定到某邀请码。支持三种归因路径:clipboard(首启读剪贴板)、manual(手动输入邀请码)、fingerprint(指纹兜底反查)。绑定成功双方各发 1 万金币。
|
||||
把当前登录用户(被邀请人)绑定到某邀请码。支持三种归因路径:clipboard(首启读剪贴板)、manual(手动输入邀请码)、fingerprint(指纹兜底反查)。**#113 起绑定只建关系、不发奖**——发奖后置到被邀请人「比价并实际下单」(`POST /order/report` 触发,给邀请人发**邀请奖励金**,`compare_reward_granted` 幂等闸一人一次)。
|
||||
|
||||
### 入参
|
||||
|
||||
@@ -46,14 +46,14 @@ Mock 入参(指纹兜底):
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `status` | string | `success` / `already_bound` / `invalid_code` / `self_invite` / `not_eligible` / `fp_not_found` |
|
||||
| `coins_awarded` | int | 本次给当前用户(被邀请人)发的金币 |
|
||||
| `coins_awarded` | int | 兼容保留字段(#113 前"绑定即发金币"口径)。**#113 起新绑定恒 0**,前端不应再据此展示发奖 |
|
||||
| `message` | string | 给前端直接展示的文案 |
|
||||
|
||||
Mock 出参:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"coins_awarded": 10000,
|
||||
"coins_awarded": 0,
|
||||
"message": "邀请绑定成功"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -25,7 +25,8 @@ GET /api/v1/invite/invitees?limit=5&offset=0
|
||||
| `items` | list[InviteeItem] | 被邀请人列表 |
|
||||
| `items[].display_name` | string | 显示名(昵称 → 微信昵称 → 脱敏手机号,后端已兜底) |
|
||||
| `items[].avatar_url` | string \| null | 头像 URL;null = 前端画默认色块 |
|
||||
| `items[].coins` | int | 这次邀请给邀请人发的金币 |
|
||||
| `items[].coins` | int | 这次邀请给邀请人发的金币(**历史留痕**:#113 前旧口径的发放额;新绑定恒 0) |
|
||||
| `items[].is_compared` | bool | 该好友是否已完成过一次比价(#113:好友列表据此分「邀请成功 / 去提醒」,在途列表只取 `false` 的) |
|
||||
| `items[].invited_at` | datetime | 邀请绑定时间(ISO 8601 UTC) |
|
||||
| `total` | int | 我邀请的总人数 |
|
||||
| `has_more` | bool | 还有下一页吗 |
|
||||
@@ -37,13 +38,15 @@ Mock 出参:
|
||||
{
|
||||
"display_name": "省钱小王",
|
||||
"avatar_url": "/media/avatars/u2_f1e2d3c4b5a60708.jpg",
|
||||
"coins": 10000,
|
||||
"coins": 0,
|
||||
"is_compared": true,
|
||||
"invited_at": "2026-06-28T14:30:00Z"
|
||||
},
|
||||
{
|
||||
"display_name": "138****1234",
|
||||
"avatar_url": null,
|
||||
"coins": 10000,
|
||||
"coins": 0,
|
||||
"is_compared": false,
|
||||
"invited_at": "2026-07-01T09:15:00Z"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -2,20 +2,21 @@
|
||||
|
||||
> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](../README.md)
|
||||
>
|
||||
> 数据来自离线库 [database/meituan_coupon](../database/meituan_coupon.md);**不实时打美团**(美团搜索对销量排序支持差、且有 402 限流)。
|
||||
> 数据来自离线库 [database/meituan_coupon](../../database/meituan_coupon.md);**不实时打美团**(美团搜索对销量排序支持差、且有 402 限流)。
|
||||
|
||||
## 入参
|
||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `page` | int | ❌ | 1 | ≥1 |
|
||||
| `page_size` | int | ❌ | 20 | 1–50 |
|
||||
| `platform` | int \| null | ❌ | null | 1 只外卖 / 2 只到店 / 不填=全部(全城销量) |
|
||||
| `platform` | int \| null | ❌ | null | 1 只外卖 / 2 只到店 / 不填=全部 |
|
||||
| `longitude` / `latitude` | float \| null | ❌(实际必带) | null | 设备坐标(#116):服务端离线反查城市(`utils/geo` + `meituan_city`)→ **只返回同城券**;老客户端不带坐标 → 返空 + `status=degraded`(不 422、不误返全城) |
|
||||
|
||||
## 出参
|
||||
响应 `200`:`{ items: CouponCard[], has_next: bool, search_id: null, status: "ok"|"empty"|"degraded" }`。`CouponCard` 见 [API 索引](./README.md#复用数据结构);`status` 语义见 [feed 接口](./meituan-feed.md#status-字段前端据此显示占位)。
|
||||
响应 `200`:`{ items: CouponCard[], has_next: bool, search_id: null, status: "ok"|"empty"|"degraded" }`。`CouponCard` 见 [API 索引](../README.md#复用数据结构);`status` 语义见 [feed 接口](./meituan-feed.md#status-字段前端据此显示占位)。
|
||||
|
||||
## 说明
|
||||
- 从 `meituan_coupon` 取 `sale_volume_num` 非空的券,`DISTINCT ON(dedup_key)` 跨源去重(每个「品牌|名|价」只留销量最高一条,同销量再按佣金),按销量降序分页;每页只对当前 ~20 条做 `from_raw` 解析(翻页快,不全表拉取)。
|
||||
- 从 `meituan_coupon` 取 `sale_volume_num` 非空 **且 `city_id` = 反查城市** 的券(#116,同城销量榜),`DISTINCT ON(dedup_key)` 跨源去重(每个「品牌|名|价」只留销量最高一条,同销量再按佣金),按销量降序分页;每页只对当前 ~20 条做 `from_raw` 解析(翻页快,不全表拉取)。
|
||||
- **不依赖 MT 凭证**(纯库查询)。库为空(prod 刚部署 / ETL 未跑完)→ `status=empty`;库查询异常 → `status=degraded`。均返 `200`、不抛 5xx。
|
||||
- **仅 PostgreSQL**(`DISTINCT ON` 为 PG 专用)。
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
# POST /api/v1/trace/finalize — 比价 trace 收尾上云
|
||||
# POST /api/v1/trace/finalize + /trace/epilogue — 比价 trace 收尾族
|
||||
|
||||
> 所属:透传端点(前缀 `/api/v1`,外卖比价) | 鉴权:无(MVP 阶段不鉴权) | [← 返回 API 索引](../README.md)
|
||||
> 所属:透传端点(前缀 `/api/v1`,外卖比价) | 鉴权:软鉴权 OptionalUser | [← 返回 API 索引](../README.md)
|
||||
|
||||
## POST /api/v1/trace/finalize — 收尾上云(+夭折落库)
|
||||
|
||||
透传到 pricebot-backend。用户终止 / Phase 1 未识别没走到 done 帧时,pricebot 没上云也没回传 `trace_url`。客户端收尾时打这个,pricebot 按 `trace_id` 一致性 hash 落到处理这条 trace 的同一进程(dir_cache 在那才能算对 trace 目录),打包上云返回 `{trace_url}`。
|
||||
|
||||
**顺手夭折落库(2026-07 起)**:app-server 把该 trace 的 [comparison_record](../../database/comparison_record.md) `running` 行更新成夭折终态(`harvest_abort`,**不降级已 success**)。body 可带 `status`(`cancelled`/`failed`)+ `reason`;老客户端只带 `trace_id` → 默认 `cancelled`,其后续 `/compare/record` 上报再补精确态。
|
||||
|
||||
## 入参
|
||||
|
||||
透传 pricebot,客户端按 pricebot 协议组装。关键字段:
|
||||
@@ -41,5 +45,15 @@ Mock 出参:
|
||||
|
||||
## 说明
|
||||
- 一致性 hash 按 `trace_id` 路由到同一 pricebot 实例(确保 dir_cache 命中)
|
||||
- MVP 阶段不鉴权
|
||||
- 软鉴权(OptionalUser,同比价透传族)
|
||||
- 与 `/intent/recognize`、`/price/step` 等同属外卖比价透传族
|
||||
|
||||
---
|
||||
|
||||
## POST /api/v1/trace/epilogue — 结果页尾声帧(#112)
|
||||
|
||||
App 收到 done、渲染完**结果页**后,把自己页面的截图(base64,body ~几百 KB)传给 pricebot 存进 trace 目录并触发重传——trace 里补上「用户实际看到的汇总页」(步骤帧只有目标 App 画面)。
|
||||
|
||||
- **纯透传壳**:不建行、不落库(该 trace 的比价记录已由 done / finalize 落终态)。
|
||||
- 入参:`{device_id, trace_id, screenshot(base64), ...}`(pricebot 协议);出参:pricebot 原样响应。
|
||||
- 错误码同 finalize(`400` / `502`)。
|
||||
|
||||
@@ -61,7 +61,24 @@ Mock 入参:
|
||||
|
||||
---
|
||||
|
||||
## POST /api/v1/user/onboarding/reset — 重置新手引导(#114)
|
||||
|
||||
删除(当前账号, `device_id`)的完成标记 → 该设备下次登录/进 App 重走引导。给客户端「设置 → 重看新手引导」入口用(此前只能运营在 admin 删记录)。
|
||||
|
||||
### 入参
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `device_id` | string | ✅ | 硬件级 ANDROID_ID(与 complete 一致) |
|
||||
|
||||
### 出参
|
||||
```json
|
||||
{"ok": true}
|
||||
```
|
||||
幂等:无标记时也返回 ok。
|
||||
|
||||
---
|
||||
|
||||
## 说明
|
||||
- 替代原 `force_onboarding`(按用户)→ 改设备维度后,运营删记录即触发重走
|
||||
- 幂等:重复标记不报错
|
||||
- 替代原 `force_onboarding`(按用户)→ 改设备维度后,运营删记录(或用户自己 reset)即触发重走
|
||||
- 幂等:重复标记 / 重复重置都不报错
|
||||
- `device_id` 为空时 `status` 一律返回未完成
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `coin_balance` | int | 当前金币余额 |
|
||||
| `cash_balance_cents` | int | 当前现金余额(分) |
|
||||
| `cash_balance_cents` | int | 当前现金余额(分,金币兑换账) |
|
||||
| `invite_cash_balance_cents` | int | 邀请奖励金余额(分,与现金**物理隔离**的第二本账,#82;好友比价并下单发奖入账,提现走 `source=invite_cash`) |
|
||||
| `total_coin_earned` | int | 累计赚取金币 |
|
||||
|
||||
## 说明
|
||||
账户不存在时自动创建(零余额)。福利页「我的资产」卡的数据源。
|
||||
账户不存在时自动创建(零余额)。福利页「我的资产」卡的数据源;邀请页「奖励金」余额也读它。
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
|---|---|---|---|---|
|
||||
| `limit` | int | ❌ | 20 | 1–100 |
|
||||
| `cursor` | int | ❌ | null | 上一页末条 `id`,首页不传 |
|
||||
| `source` | string | ❌ | null | 按账户来源过滤:`coin_cash` / `invite_cash`;不传=全部(#121) |
|
||||
|
||||
## 出参
|
||||
响应 `200`:`{ items: WithdrawOrderOut[], next_cursor: int|null }`(分页见 [索引#游标分页约定](./README.md#游标分页约定))
|
||||
响应 `200`:`{ items: WithdrawOrderOut[], next_cursor: int|null }`(分页见 [索引#游标分页约定](../README.md#游标分页约定))
|
||||
|
||||
**WithdrawOrderOut**
|
||||
|
||||
@@ -19,7 +20,7 @@
|
||||
| `id` | int | 单 id(也是游标) |
|
||||
| `out_bill_no` | string | 商户提现单号 |
|
||||
| `amount_cents` | int | 提现额(分) |
|
||||
| `status` | string | `pending` / `success` / `failed` |
|
||||
| `status` | string | `reviewing`(待审核)/ `pending` / `success` / `failed` / `rejected` |
|
||||
| `wechat_state` | string \| null | 微信侧原始状态 |
|
||||
| `fail_reason` | string \| null | 失败原因 |
|
||||
| `created_at` | datetime | 发起时间 |
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:Bearer | 限流:同 IP ≤20 次/分 | [← 返回 API 索引](../README.md)
|
||||
>
|
||||
> 集成实现:见 [integrations/wxpay](../integrations/wxpay.md)(微信 V3 商家转账、签名、实名加密)。
|
||||
> 集成实现:见 [integrations/wxpay](../../integrations/wxpay.md)(微信 V3 商家转账、签名、实名加密)。
|
||||
|
||||
## 入参
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `amount_cents` | int | ✅(>0) | 提现金额(分),须落在 `[min_cents, max_cents]` |
|
||||
| `source` | string | ❌(默认 `coin_cash`) | 提现账户来源(#121 分账):`coin_cash`(金币兑换的现金)/ `invite_cash`(邀请奖励金)。按它扣对应余额、流水落对应账本([cash_transaction](../../database/cash_transaction.md) / [invite_cash_transaction](../../database/invite_cash_transaction.md)) |
|
||||
| `user_name` | string | ❌ | 实名(达额时微信商家转账要求,可空) |
|
||||
| `out_bill_no` | string | ❌ | **客户端幂等键(商户单号)**:同号重试不重复转账;不传则服务端生成 |
|
||||
|
||||
|
||||
+69
-28
@@ -2,7 +2,7 @@
|
||||
|
||||
> 跨表视角。单表字段级细节看同目录 `<表名>.md`(索引见 [README](./README.md))。
|
||||
> 本文专门回答三件「跨表」的事:**① 每块 App 功能用到哪些表 ② 什么操作往哪张表写 ③ 表和表怎么连(join key,含没有外键约束、靠业务字段对齐的语义关联)**。
|
||||
> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **40 张业务表** + `alembic_version`(框架的迁移版本指针)。注意「比价/领券**过程**」始终在 pricebot 内存态跑、**不落库**——只有**结果**回 app-server 才落库:领券结果落 `coupon_*` 三张今日状态表;比价结果分两路——客户端带 JWT 上报「我的记录」落 `comparison_record`,pricebot 另经 `app/api/internal/` server→server 把客观价格/门店事实落 `price_observation`/`store_mapping`(不鉴权、匿名也记)+ 启动确认窗兜底样本落 `launch_confirm_sample`。此外好友邀请(`invite_*` 2 张)与美团 CPS 群发联盟(`cps_*` 6 张,含落地页微信身份 `cps_wx_user`)是两个独立子系统。无障碍存活监控 `device_liveness`(#65)按用户设备维度记心跳、检掉线召回。
|
||||
> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **45 张业务表** + `alembic_version`(框架的迁移版本指针)。注意「比价/领券**过程**」始终在 pricebot 内存态跑、**不落库**——只有**结果**回 app-server 才落库:领券结果落 `coupon_*` 三张今日状态表 + `coupon_session` 任务流水(#99);比价记录 2026-07 起**由 app-server 透传壳 harvest 直接落库**(`comparison_record`:帧0 建 running 行 → done/finalize 写终态,老客户端带 JWT 的 `POST /compare/record` 仅作兜底),pricebot 另经 `app/api/internal/` server→server 把客观价格/门店事实落 `price_observation`/`store_mapping`(不鉴权、匿名也记)+ 启动确认窗兜底样本落 `launch_confirm_sample`。此外好友邀请(`invite_*` 2 张 + 独立账本 `invite_cash_transaction`)与 CPS 群发联盟(`cps_*` 6 张,含落地页微信身份 `cps_wx_user`;`cps_order` 已含京东联盟单)是两个独立子系统。无障碍存活监控 `device_liveness`(#65)按用户设备维度记心跳、检掉线召回;客户端埋点落 `analytics_event`(#83)。
|
||||
|
||||
---
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
| App 位置 / 动作 | 表 | 说明 |
|
||||
|---|---|---|
|
||||
| 比价/领券**过程**(看屏→决策→操作) | (无) | 在 pricebot-backend 内存态跑,**过程不落库**;只有结果回到 app-server 才落库 |
|
||||
| 「我的比价记录」列表 / 详情 | [`comparison_record`](./comparison_record.md) | 每次比价 done 后客户端带 JWT 上报一条完整明细 |
|
||||
| 「我的比价记录」列表 / 详情 | [`comparison_record`](./comparison_record.md) | **app-server 透传壳 harvest 落库**(2026-07 起):帧0 建 `running` 行 → done 帧写 success/failed → `trace/finalize` 写 cancelled/failed;老客户端带 JWT 的 `POST /compare/record` 兜底 |
|
||||
| 比价战绩里程碑(逐档领金币) | [`comparison_milestone_claim`](./comparison_milestone_claim.md) | 累计成功比价 N 次解锁;进度读 `comparison_record` 计数 |
|
||||
| profile「累计省了 / 省钱战绩 / 省钱明细」 | [`savings_record`](./savings_record.md) | 真实下单归因(source=compare)+ 无真实数据时 demo 兜底 |
|
||||
| 「上报更低价」提交 / 列表 | [`price_report`](./price_report.md) | 众包纠偏:用户举证某平台更便宜,人工审核发奖 |
|
||||
@@ -27,6 +27,7 @@
|
||||
| 切外卖 App 时是否弹领券引导窗 | [`coupon_prompt_engagement`](./coupon_state.md) | 今天 engage 过(点领/点拒)就不再弹;判断维度 device_id |
|
||||
| 首页「去领取」卡是否置灰 | [`coupon_daily_completion`](./coupon_state.md) | 今天跑完整轮(到 done)就置灰;判断维度 device_id |
|
||||
| 每张券领取结果留痕 | [`coupon_claim_record`](./coupon_state.md) | 资产/画像/排查/CPS;当前**不参与**判断 |
|
||||
| (admin 领券看板)一次领券任务全程流水 | [`coupon_session`](./coupon_session.md) | 客户端 `POST /coupon/session` 两段上报(发起建行/收尾更新);发起数、完成率、中途流失、平均耗时都从这算(#99) |
|
||||
|
||||
### 钱包 / 福利(看广告赚钱闭环)
|
||||
| App 位置 / 动作 | 表 | 说明 |
|
||||
@@ -37,7 +38,9 @@
|
||||
| 每日签到 | [`signin_record`](./signin_record.md) + [`signin_boost_record`](./signin_boost_record.md) | 7 天循环发币;签到后看广告可膨胀一次 |
|
||||
| 一次性任务(开消息提醒等) | [`user_task`](./user_task.md) | 领一次发币 |
|
||||
| 看激励视频赚金币 | [`ad_reward_record`](./ad_reward_record.md) + [`ad_watch_log`](./ad_watch_log.md) + [`ad_ecpm_record`](./ad_ecpm_record.md) | 独立数据流:发奖 / 旧版观看时长 / 收益对账 |
|
||||
| 信息流广告结算 | [`ad_feed_reward_record`](./ad_feed_reward_record.md) | 每展示满 10 秒累计一份奖励,完成后一次性入账 |
|
||||
| 信息流/Draw 广告结算 | [`ad_feed_reward_record`](./ad_feed_reward_record.md) | 每展示满 10 秒累计一份奖励,完成后一次性入账;`ad_type`(feed/draw)+`feed_scene`(compare/coupon)分形态/场景 |
|
||||
| (无 App UI)穿山甲后台收益对账 | [`ad_pangle_daily_revenue`](./ad_pangle_daily_revenue.md) | 定时脚本拉 GroMore 数据 API 落日表(#92);admin 收益报表/大盘的「真实收益」侧 |
|
||||
| 好友比价并下单发奖 / 邀请奖励金 | [`invite_cash_transaction`](./invite_cash_transaction.md) + `coin_account.invite_cash_balance_cents` | 与现金**物理隔离**的第二本账(#82/#113):`POST /order/report` 触发 `invite_reward` 入账;`source=invite_cash` 提现出账 |
|
||||
| 金币兑现金 | `coin_account` + `coin_transaction` + `cash_transaction` | exchange_out + exchange_in 两笔流水 |
|
||||
| 提现到微信零钱 | [`withdraw_order`](./withdraw_order.md) + [`wechat_transfer_authorization`](./wechat_transfer_authorization.md) + `cash_transaction` | 人工审核 + 微信商家转账 |
|
||||
| 绑定微信(提现前置) | `user`.wechat_* | openid 唯一,一微信一账号 |
|
||||
@@ -53,8 +56,9 @@
|
||||
### 好友邀请(注册增长)
|
||||
| App 位置 / 动作 | 表 | 说明 |
|
||||
|---|---|---|
|
||||
| 输入/剪贴板邀请码绑定 | [`invite_relation`](./invite_relation.md) | 注册即生效,邀请人+被邀请人各发 1 万金币;`invitee_user_id` 唯一=幂等防重复发奖 |
|
||||
| 输入/剪贴板邀请码绑定 | [`invite_relation`](./invite_relation.md) | 注册即生效(**绑定不发奖**,#113);`invitee_user_id` 唯一;`compare_reward_granted` 幂等闸=好友**比价并下单**后才给邀请人发奖励金 |
|
||||
| 落地页访问指纹(剪贴板归因兜底) | [`invite_fingerprint`](./invite_fingerprint.md) | 剪贴板没拿到码时,用 (ip+机型+屏幕) 7 天内反查邀请人 |
|
||||
| 邀请奖励金入账/提现 | [`invite_cash_transaction`](./invite_cash_transaction.md) | 独立账本(见上钱包节);余额在 `coin_account.invite_cash_balance_cents` |
|
||||
|
||||
### 美团 CPS 群发联盟(私域社群比价,运营后台驱动 · 群发选品→点击→对账漏斗)
|
||||
| 后台/用户动作 | 表 | 说明 |
|
||||
@@ -66,10 +70,19 @@
|
||||
| 微信内打开落地页授权 | [`cps_wx_user`](./cps_wx_user.md) | 服务号网页授权拿 openid(base 静默)/ 昵称头像 unionid(userinfo,点领券触发),记首次来源群 |
|
||||
| 定时拉美团联盟订单对账 | [`cps_order`](./cps_order.md) | `query_order` 按 sid 归群,串成点击→下单→佣金漏斗 |
|
||||
|
||||
### 埋点 / 首页门面 / 选品缓存
|
||||
| 位置 / 动作 | 表 | 说明 |
|
||||
|---|---|---|
|
||||
| 客户端行为埋点 | [`analytics_event`](./analytics_event.md) | `POST /analytics/events` 批量上报,一事件一行(#83);admin「埋点日志」检索 |
|
||||
| 首页三统计数字 | [`ops_stat_config`](./ops_stat_config.md) | real/manual/random 三模式,admin 配 |
|
||||
| 首页轮播(省钱动态) | [`ops_marquee_seed`](./ops_marquee_seed.md) | 种子条目,与真实 `comparison_record` 混播(数据源模式 mixed/real/seed 可切) |
|
||||
| 首页推荐/销量榜离线选品 | [`meituan_coupon`](./meituan_coupon.md) | 美团 CPS 券缓存,定时 ETL 灌入;`feed?tab=rec` 与 `top-sales` 纯库出、不实时打美团 |
|
||||
|
||||
### 运营后台 admin(独立子应用 `app/admin/`,端口 8771,独立鉴权)
|
||||
| 后台模块 | 表 | 说明 |
|
||||
|---|---|---|
|
||||
| 管理员账号 / 登录 | [`admin_user`](./admin_user.md) | 与 C 端 `user` 完全隔离,独立 JWT + RBAC |
|
||||
| 管理员账号 / 登录 | [`admin_user`](./admin_user.md) | 与 C 端 `user` 完全隔离,独立 JWT + RBAC;`pages_override` 个人可见页覆盖 |
|
||||
| 角色 / 可见页配置 | [`admin_role`](./admin_role.md) | 内建三角色 + 自定义角色(#117/#126);`admin_user.role` 按名引用 |
|
||||
| 操作审计 | [`admin_audit_log`](./admin_audit_log.md) | 每个写操作落一条,只增不改不删 |
|
||||
| 运营可配置项(改奖励常量) | [`app_config`](./app_config.md) | 空表 = 用代码默认;后台改了即覆盖 |
|
||||
| 用户/钱包/提现/反馈管理 | 跨读写上面的 C 端表 | 见下「写入路径」admin 段 |
|
||||
@@ -92,8 +105,8 @@
|
||||
| 签到膨胀 `POST /signin/boost` | `signin_boost_record`(C) + `coin_account`(U) + `coin_transaction`(C `signin_boost`) | 同事务;同日一次 |
|
||||
| 领任务 `POST /tasks/claim` | `user_task`(C) + `coin_account`(U) + `coin_transaction`(C `task_<key>`) | 同事务 |
|
||||
| 金币兑现金 `POST /wallet/exchange` | `coin_account`(U) + `coin_transaction`(C `exchange_out` −) + `cash_transaction`(C `exchange_in` +) | 同事务 |
|
||||
| 发起提现 `POST /wallet/withdraw` | `withdraw_order`(C `reviewing`) + `coin_account`(U 扣现金) + `cash_transaction`(C `withdraw` −) | 同事务,**不打款** |
|
||||
| 查提现状态 / 用户取消 `GET /wallet/withdraw/status` | `withdraw_order`(U) + 失败→`cash_transaction`(C `withdraw_refund` +) | |
|
||||
| 发起提现 `POST /wallet/withdraw` | `withdraw_order`(C `reviewing`,记 `source`) + `coin_account`(U 按 source 扣对应余额) + 流水(C −:`cash_transaction.withdraw` 或 `invite_cash_transaction.invite_withdraw`) | 同事务,**不打款**;#121 按 `source` 分账 |
|
||||
| 查提现状态 / 用户取消 `GET /wallet/withdraw/status` | `withdraw_order`(U) + 失败→对应账本退款流水(C `withdraw_refund` / `invite_withdraw_refund` +) | |
|
||||
| 穿山甲发奖 S2S 回调 `POST /ad/pangle-callback` | `ad_reward_record`(C)+ granted→`coin_account`(U)+`coin_transaction`(C `reward_video`/`signin_boost`) | `trans_id` 幂等 |
|
||||
| 看广告时长上报 `POST /ad/watch-report` | `ad_watch_log`(C) | |
|
||||
| 广告 eCPM 上报 `POST /ad/ecpm-report` | `ad_ecpm_record`(C) | |
|
||||
@@ -101,13 +114,17 @@
|
||||
| 注册设备 / 更新 push token `POST /device/register` | `device_liveness`(C/U upsert) | `(user_id, device_id)` 幂等;只在传入非空时更新 `registration_id`/版本 |
|
||||
| 无障碍服务心跳 `POST /device/heartbeat` | `device_liveness`(C/U) | 心跳也能自注册;`accessibility_enabled=true` 时刷 `last_heartbeat_at`、置 `alive`、清 `notified_at` |
|
||||
| 客户端 ack 掉线提醒 `POST /device/liveness/ack` | `device_liveness`(U) | 清 `kill_alert_pending`(幂等;下次真掉线 worker 再置) |
|
||||
| 比价 done 上报 `POST /compare/record` | `comparison_record`(C 或 U) | `(user_id, trace_id)` 幂等覆盖 |
|
||||
| 比价透传(帧0 / done / finalize)`POST /intent/recognize`、`/price/step`、`/trace/finalize` | `comparison_record`(C `running` → U 终态) | **harvest 三段式**(2026-07):帧0 mint trace_id 建 running 行 → done 写 success/failed → finalize 写 cancelled/failed(不降级 success);best-effort,写失败不连累透传 |
|
||||
| 比价 done 上报 `POST /compare/record`(老客户端兜底) | `comparison_record`(C 或 U) | `trace_id` 幂等覆盖(唯一键已从 `(user_id,trace_id)` 改单列) |
|
||||
| 领里程碑 `POST /compare/milestone/claim` | `comparison_milestone_claim`(C) | **当前不发币**(coin_awarded=0) |
|
||||
| 支付归因上报 `POST /order/report` | `savings_record`(C `source=compare`) | `(user_id, client_event_id)` 幂等 |
|
||||
| 支付归因上报 `POST /order/report` | `savings_record`(C `source=compare`)+ 触发邀请发奖:`invite_relation`(U `compare_reward_granted`)+`coin_account`(U invite_cash)+`invite_cash_transaction`(C `invite_reward`) | `(user_id, client_event_id)` 幂等;#113 邀请发奖口径=好友**比价并下单**,`compare_reward_granted` 幂等闸,同事务 |
|
||||
| 批量埋点 `POST /analytics/events` | `analytics_event`(C 批量) | 不强制登录;每批 ≤200 条 |
|
||||
| 领券任务流水 `POST /coupon/session` | `coupon_session`(C/U upsert) | `trace_id` 幂等:发起建行、收尾更新同一行(#99) |
|
||||
| 重置新手引导 `POST /user/onboarding/reset` | `onboarding_completion`(**D** 该 设备+账号 行) | #114,删完成标记 → 下次登录重走引导 |
|
||||
| 首次进 profile 省钱页且无真实记录 | `savings_record`(C `source=demo`) | 懒种子,`ensure_seeded` 按 user 幂等 |
|
||||
| 上报更低价 `POST /report` | `price_report`(C) | 读 `comparison_record.best_price_cents` 校验 |
|
||||
| 提交反馈 `POST /feedback` | `feedback`(C) | |
|
||||
| 绑定邀请 `POST /invite/bind` | `invite_relation`(C `effective`) + `coin_account`(U×2) + `coin_transaction`(C `invite_inviter` + `invite_invitee`) | 同事务;`invitee_user_id` 唯一幂等,双方各发 1 万金币 |
|
||||
| 绑定邀请 `POST /invite/bind` | `invite_relation`(C `effective`) | `invitee_user_id` 唯一幂等。**#113 起绑定不发奖**——发奖延后到好友比价并下单(`POST /order/report` 行),发的是邀请奖励金非金币 |
|
||||
| 落地页归因 `POST /invite/landing-track` | `invite_fingerprint`(C) | 剪贴板归因兜底线索,登录后用 (ip+机型+屏幕) 反查 |
|
||||
| 领券首帧 `POST /api/v1/coupon/step`(step=0) | `coupon_prompt_engagement`(C/U `claim_started`) | `(device_id, package, 北京日)` 幂等;best-effort |
|
||||
| 领券每帧结果 `POST /api/v1/coupon/step` | `coupon_claim_record`(C/U) | `(device_id, coupon_id, 北京日)` 幂等;best-effort |
|
||||
@@ -121,11 +138,17 @@
|
||||
| 后台操作 | 写入 | 操作 |
|
||||
|---|---|---|
|
||||
| 手动增减金币 | `coin_account`(U)+`coin_transaction`(C `admin_grant`/`admin_deduct`)+`admin_audit_log`(C) | 同事务 |
|
||||
| 改用户状态(禁用/启用) | `user`(U)+`admin_audit_log`(C) | 同事务 |
|
||||
| 审核通过提现 | `withdraw_order`(U→pending/success/failed)+`wechat_transfer_authorization`(C/U)+失败时`cash_transaction`(refund)+`admin_audit_log`(C) | |
|
||||
| 审核拒绝提现 | `withdraw_order`(U→rejected)+`cash_transaction`(C `withdraw_refund`)+`admin_audit_log`(C) | |
|
||||
| 处理反馈 | `feedback`(U)+`admin_audit_log`(C) | 同事务 |
|
||||
| 改运营配置 | `app_config`(C/U)+`admin_audit_log`(C) | 同事务 |
|
||||
| 手动增减现金(#95 支持目标账户) | `coin_account`(U 按 `account` 选列)+ 流水(C:`cash_transaction` 或 `invite_cash_transaction`,`admin_grant`/`admin_deduct`)+`admin_audit_log`(C) | 同事务;`account=coin_cash`/`invite_cash` 两本账各调各 |
|
||||
| 改用户状态(禁用/启用)/ 调试链接权限 | `user`(U `status` / `debug_trace_enabled`)+`admin_audit_log`(C) | 同事务 |
|
||||
| 审核通过提现(含批量) | `withdraw_order`(U→pending/success/failed)+`wechat_transfer_authorization`(C/U)+失败时按 source 退款流水+`admin_audit_log`(C) | |
|
||||
| 审核拒绝提现(含批量) | `withdraw_order`(U→rejected)+按 source 退款流水(C `withdraw_refund`/`invite_withdraw_refund`)+`admin_audit_log`(C) | |
|
||||
| 反馈采纳/拒绝/标记处理 | `feedback`(U→adopted/rejected/handled + `admin_reply`/`reject_reason`)+采纳发币时 `coin_account`(U)+`coin_transaction`(C)+`admin_audit_log`(C) | 同事务(#94/#105) |
|
||||
| 上报更低价审核 | `price_report`(U→approved/rejected)+通过发币 `coin_account`(U)+`coin_transaction`(C)+`admin_audit_log`(C) | 同事务 |
|
||||
| 改运营配置 / 广告配置 / 反馈页二维码 | `app_config`(C/U)+`admin_audit_log`(C) | 同事务 |
|
||||
| 轮播种子/数据源模式 | `ops_marquee_seed`(C/U/D)+`app_config`(模式 mixed/real/seed)+`admin_audit_log`(C) | #122/#123 预览/浏览只读 |
|
||||
| 角色管理(#117/#126) | `admin_role`(C/U/D)+`admin_audit_log`(C) | 内建/在用角色不可删 |
|
||||
| 管理员管理 | `admin_user`(C/U/**D** #126)+`admin_audit_log`(C) | 删除为物理删,不可删自己 |
|
||||
| CPS 建群/活动/生成短链/拉单对账 | `cps_group`/`cps_activity`(C/U/D)、`cps_link`(C)、`cps_order`(C/U upsert;美团 `query_order` + 京东联盟 #90) | 见 CPS 段 |
|
||||
| 任意写操作 | `admin_audit_log`(C,**永不 U/D**) | |
|
||||
|
||||
> 没有任何表会被业务流程物理 DELETE。注销是软删(改 user 行),其余只 C/U(领券 `/prompt/reset`、`/completed-today/reset` 是开发用删除,非业务流程)。
|
||||
@@ -137,7 +160,9 @@
|
||||
| 后台批量生成短链 `POST /admin/api/cps/referral-links` | `cps_link`(C) | 每 群×活动 一条;美团经 `sid` 转链拿 `target_url`(同群同活动重复生成产生多条) |
|
||||
| 用户点群发短链 `GET /c/{code}` / `POST /c/{code}/copy` | `cps_click`(C `visit`/`copy`) | 公开端点不鉴权;`group_id`/`sid` 从 link 冗余进来免 join |
|
||||
| 微信落地页授权回调 `GET /wx/oauth/cb` | `cps_wx_user`(C/U upsert) | 按 `openid` 幂等;base 拿 openid,userinfo 补昵称/unionid(非 None 才覆盖);任何失败兜底回落地页不阻断领券 |
|
||||
| 定时拉美团联盟订单对账 | `cps_order`(C/U upsert) | `query_order` 按 `sid` 归群;`order_id` 幂等(状态会变,重复拉则更新) |
|
||||
| 定时拉联盟订单对账(美团 `query_order` + 京东联盟 #90) | `cps_order`(C/U upsert) | 按 `sid` 归群;`order_id` 幂等(状态会变,重复拉则更新);京东单 `platform='jd'` + `jd_valid_code` 判有效 |
|
||||
| 定时拉穿山甲 GroMore 后台收益(#92,systemd 每天 10:30) | `ad_pangle_daily_revenue`(C/U upsert) | 收益报表/大盘「真实收益」侧;与客户端上报的 eCPM 侧互为对照 |
|
||||
| 定时 ETL 灌美团 CPS 选品库 | `meituan_coupon`(C/U) | `feed?tab=rec` / `top-sales` 纯库出的数据源 |
|
||||
| pricebot 比价 done 内部上报 `POST /internal/price-observation` | `price_observation`(C 批量) | `(trace_id,platform,scope)` 幂等;**不走 JWT、靠 `X-Internal-Secret`**(未配→503) |
|
||||
| pricebot 比价 done 内部上报 `POST /internal/store-mapping` | `store_mapping`(C/U 填空合并) | `trace_id` 幂等;另有 `lookup` 反查 + `invalidate` deeplink 失效标记(淘宝/京东) |
|
||||
| pricebot LLM 兜底放行启动确认窗后上报 `POST /internal/launch-confirm-sample` | `launch_confirm_sample`(C) | **都上报、不去重**;靠 `X-Internal-Secret`(未配→503) |
|
||||
@@ -150,7 +175,7 @@
|
||||
## 三、表间关系 & Join Key
|
||||
|
||||
### 硬外键(数据库 FK 约束)
|
||||
- **18 张用户维度表 `.user_id` → `user.id`**:`coin_account`(同时是 PK)、`coin_transaction`、`cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`(同时是 PK)、`signin_record`、`signin_boost_record`、`user_task`、`comparison_record`、`comparison_milestone_claim`、`savings_record`、`ad_reward_record`、`ad_watch_log`、`ad_ecpm_record`、`ad_feed_reward_record`、`price_report`、`feedback`、`device_liveness`。
|
||||
- **19 张用户维度表 `.user_id` → `user.id`**:`coin_account`(同时是 PK)、`coin_transaction`、`cash_transaction`、`invite_cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`(同时是 PK)、`signin_record`、`signin_boost_record`、`user_task`、`comparison_record`(2026-07 起 `user_id` **可空**——harvest 帧0 建行时软鉴权可能拿不到)、`comparison_milestone_claim`、`savings_record`、`ad_reward_record`、`ad_watch_log`、`ad_ecpm_record`、`ad_feed_reward_record`、`price_report`、`feedback`、`device_liveness`。
|
||||
- `admin_audit_log.admin_id` → `admin_user.id`。
|
||||
- `price_report.comparison_record_id` → `comparison_record.id`(可空:关联记录被删后仍留上报历史)。
|
||||
- **邀请两表** → `user.id`:`invite_relation.inviter_user_id`、`invite_relation.invitee_user_id`(唯一)、`invite_fingerprint.inviter_user_id`——注意 FK 列名是 `inviter`/`invitee_user_id`,不是 `user_id`。
|
||||
@@ -173,13 +198,24 @@
|
||||
|
||||
| biz_type | ref_id 指向 | amount 符号 |
|
||||
|---|---|---|
|
||||
| `withdraw` / `withdraw_refund` | `withdraw_order.out_bill_no` | − / + |
|
||||
| `withdraw` / `withdraw_refund` | `withdraw_order.out_bill_no`(`source=coin_cash` 的单) | − / + |
|
||||
| `exchange_in` | null | + |
|
||||
| `admin_grant` / `admin_deduct` | null(原因在 `remark`=`admin:<reason>`) | + / − |
|
||||
|
||||
- **`invite_cash_transaction.ref_id`**(邀请奖励金账本,#82):
|
||||
|
||||
| biz_type | ref_id 指向 | amount 符号 |
|
||||
|---|---|---|
|
||||
| `invite_reward` | 被邀请人 `user.id`(字符串) | + |
|
||||
| `invite_withdraw` / `invite_withdraw_refund` | `withdraw_order.out_bill_no`(`source=invite_cash` 的单) | − / + |
|
||||
| `admin_grant` / `admin_deduct` | null | + / − |
|
||||
|
||||
- **`comparison_record.store_name` ≈ `savings_record.shop_name`**:无 id 关联,按**店名字符串相等**给比价记录打「已下单」标记(瞬态,不写库)。两边店名同源 = 比价意图识别阶段的门店 query,语义=**店级**(同店比价多次会一并标已下单)。
|
||||
- **广告流会话关联**:`ad_reward_record.ad_session_id` 可与 `ad_ecpm_record.ad_session_id` 对齐;`ad_watch_log` 仍是旧版兼容统计,不逐条参与发奖。
|
||||
- **里程碑解锁进度不存库**:`comparison_milestone_claim` 只记「哪几档已领」;进度 = `comparison_record` 里 `status='success'` 的 `count`。
|
||||
- **领券三表无硬 FK,全靠软关联**:`coupon_prompt_engagement` / `coupon_daily_completion` / `coupon_claim_record` 的 `user_id` **软指** `user.id`(可空、有登录态才记、不进唯一键、不阻塞判断);`trace_id` **软指** pricebot work_logs(排查回指);唯一键都以 `device_id` + 北京自然日为主(详见 [`coupon_state.md`](./coupon_state.md))。
|
||||
- **领券三表无硬 FK,全靠软关联**:`coupon_prompt_engagement` / `coupon_daily_completion` / `coupon_claim_record` 的 `user_id` **软指** `user.id`(可空、有登录态才记、不进唯一键、不阻塞判断);`trace_id` **软指** pricebot work_logs(排查回指);唯一键都以 `device_id` + 北京自然日为主(详见 [`coupon_state.md`](./coupon_state.md))。[`coupon_session`](./coupon_session.md)(#99)同口径:`trace_id` 唯一 upsert、`user_id` 软指(admin 明细 LEFT JOIN 出手机号)。
|
||||
- **`analytics_event`**(#83)与 **`coupon_session`** 均无硬 FK:埋点/流水不鉴权也收,`device_id`/`user_id` 只作维度。
|
||||
- **`admin_user.role` 按名语义引用 `admin_role.name`**(无硬 FK,#117):删除保护在应用层(在用/内建角色不可删);个人 `pages_override` 优先于角色 `pages`。
|
||||
- **`onboarding_completion.(user_id, device_id)`**:`user_id` 语义关联 `user.id`(无硬 FK,同 `coupon_*` 设备表),`device_id` = 客户端硬件级 `ANDROID_ID`(≠ 领券 per-install `device_id`)。登录读、走完引导写,决定是否再展示新手引导。
|
||||
- **CPS 群发 6 表全靠 `sid` / id / `openid` 语义串联(无硬 FK)**:`cps_link.group_id`→`cps_group.id`、`cps_link.activity_id`→`cps_activity.id`、`cps_click.link_id`→`cps_link.id`、`cps_wx_user.first_group_id`→`cps_group.id`;**点击与订单无法对到单笔**,只在群维度(`sid`)汇合——`cps_order.sid` ≈ `cps_group.sid` ≈ `cps_link.sid` ≈ `cps_click.sid`(仅美团有 sid,淘宝/京东无)。统计按群聚合,故 `group_id`/`sid` 冗余进 `cps_click` 免 join。`cps_wx_user` 按 `openid` 自成用户身份维度,与 `cps_click`/`cps_order` 无 id 级 join。
|
||||
- **比价沉淀两表(`price_observation` / `store_mapping`)**:`trace_id` 软指 pricebot work_logs(与 `comparison_record.trace_id` 同源但不互 join,各存各视角);`source_user_id` / `source_device_id` 软指用户/设备(可空,匿名也记)。`store_mapping.lookup` 靠**店名字符串精确相等** + geo 取最近,非 id 级 join。
|
||||
@@ -188,10 +224,10 @@
|
||||
```
|
||||
user ─1:1─ coin_account
|
||||
user ─1:1─ wechat_transfer_authorization
|
||||
user ─1:N─ { coin_transaction, cash_transaction, withdraw_order, signin_record,
|
||||
signin_boost_record, user_task, comparison_record, comparison_milestone_claim,
|
||||
savings_record, ad_reward_record, ad_watch_log, ad_ecpm_record, ad_feed_reward_record,
|
||||
price_report, feedback, device_liveness }
|
||||
user ─1:N─ { coin_transaction, cash_transaction, invite_cash_transaction, withdraw_order,
|
||||
signin_record, signin_boost_record, user_task, comparison_record(user_id 可空),
|
||||
comparison_milestone_claim, savings_record, ad_reward_record, ad_watch_log,
|
||||
ad_ecpm_record, ad_feed_reward_record, price_report, feedback, device_liveness }
|
||||
(device_liveness 硬 FK; (user_id,device_id) 唯一)
|
||||
user ─1:N─ onboarding_completion (user_id, 无硬 FK; (user_id,device_id) 去重)
|
||||
comparison_record ─1:N─ price_report (comparison_record_id, 可空)
|
||||
@@ -199,6 +235,11 @@ admin_user ─1:N─ admin_audit_log
|
||||
app_config (独立, 无外键, key 为主键)
|
||||
coupon_prompt_engagement / coupon_daily_completion / coupon_claim_record
|
||||
(独立, 无硬 FK; 维度=device_id+北京日, user_id/trace_id 仅软关联)
|
||||
coupon_session (独立, 无硬 FK; trace_id 唯一=一次领券任务一行, #99)
|
||||
analytics_event (独立, 无硬 FK; 埋点事件流, device/user 仅维度, #83)
|
||||
admin_role ◀──语义(role 按 name 引用, 无FK)── admin_user (pages_override 个人覆盖, #117/#126)
|
||||
ops_stat_config / ops_marquee_seed / meituan_coupon / ad_pangle_daily_revenue
|
||||
(独立运营/缓存/对账表, 无外键)
|
||||
user ─1:N─ invite_relation (inviter_user_id 硬 FK); invitee_user_id ─1:1─ user (唯一硬 FK)
|
||||
user ─1:N─ invite_fingerprint (inviter_user_id 硬 FK)
|
||||
cps_activity / cps_group ──语义(无FK)──▶ cps_link ─1:N─ cps_click
|
||||
@@ -212,13 +253,13 @@ launch_confirm_sample (独立, 无硬 FK; 都上报不去
|
||||
|
||||
## 四、资金模型(金币 / 现金 / 提现,三层)
|
||||
|
||||
1. **余额快照** `coin_account`:`coin_balance`(金币个数)+ `cash_balance_cents`(现金分),一用户一行,读取展示用。
|
||||
2. **流水账本** `coin_transaction` / `cash_transaction`:每次变动写一笔,`balance_after*` 记变动后余额,可逐笔回溯对账。
|
||||
3. **唯一发金币入口** `repositories/wallet.grant_coins`:更新快照 + 写流水,**不 commit**,由调用方在同一事务里 commit(保证"记录"和"加币"原子化)。signin / signin_boost / task / ad_reward / feed_ad_reward / exchange / admin 都走它,靠 `biz_type` 区分来源。
|
||||
1. **余额快照** `coin_account`:`coin_balance`(金币个数)+ `cash_balance_cents`(现金分)+ `invite_cash_balance_cents`(邀请奖励金分,#82),一用户一行,读取展示用。
|
||||
2. **流水账本** `coin_transaction` / `cash_transaction` / `invite_cash_transaction`:每次变动写一笔,`balance_after*` 记变动后余额,可逐笔回溯对账。**现金与邀请奖励金是两本物理隔离的账**——发放口径与提现对账各自独立。
|
||||
3. **唯一变动入口**:金币走 `repositories/wallet.grant_coins`,邀请奖励金走 `grant_invite_cash`——都是「更新快照 + 写流水,**不 commit**,由调用方同一事务 commit」。signin / signin_boost / task / ad_reward / feed_ad_reward / exchange / admin 走 `grant_coins`;`invite_reward` / admin 调整走 `grant_invite_cash`,靠 `biz_type` 区分来源。
|
||||
|
||||
- **汇率**:`10000 金币 = 1 元 = 100 分`(`rewards.COIN_PER_YUAN`);兑换额必须是整分倍数。
|
||||
- **提现状态机**:`reviewing`(发起即原子扣现金、待人工审核、**不打款**)→ 审核通过 `pending`(微信转账在途)→ `success` / `failed`(失败自动退款);审核拒绝 `rejected`(退款)。扣款/退款都写 `cash_transaction`,`out_bill_no` 幂等,孤儿 pending 单由 `reconcile_pending_withdraws` 对账兜底。
|
||||
- **防超额**:扣现金用带条件 `UPDATE ... WHERE cash_balance_cents >= amount`,并发/重试不会双扣。
|
||||
- **提现状态机**:`reviewing`(发起即原子扣款、待人工审核、**不打款**)→ 审核通过 `pending`(微信转账在途)→ `success` / `failed`(失败自动退款);审核拒绝 `rejected`(退款)。**按 `withdraw_order.source` 分账**(#121):`coin_cash` 单的扣款/退款写 `cash_transaction`,`invite_cash` 单写 `invite_cash_transaction`;`out_bill_no` 幂等,孤儿 pending 单由 `reconcile_pending_withdraws` 对账兜底,admin `withdraws/ledger-check` 分账校验「单 ↔ 流水」。
|
||||
- **防超额**:扣款用带条件 `UPDATE ... WHERE <对应余额列> >= amount`(按 source 选 `cash_balance_cents` / `invite_cash_balance_cents`),并发/重试不会双扣。
|
||||
|
||||
---
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user