Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4b94d335d |
+1
-21
@@ -29,7 +29,7 @@ JG_REQUEST_TIMEOUT_SEC=15
|
|||||||
|
|
||||||
# ===== 无障碍保护存活监控(pull 后置检测;本期不接推送)=====
|
# ===== 无障碍保护存活监控(pull 后置检测;本期不接推送)=====
|
||||||
HEARTBEAT_MONITOR_ENABLED=true
|
HEARTBEAT_MONITOR_ENABLED=true
|
||||||
HEARTBEAT_TIMEOUT_MINUTES=60
|
HEARTBEAT_TIMEOUT_MINUTES=10
|
||||||
HEARTBEAT_SCAN_INTERVAL_SEC=60
|
HEARTBEAT_SCAN_INTERVAL_SEC=60
|
||||||
|
|
||||||
# ===== 短信 (mock 模式) =====
|
# ===== 短信 (mock 模式) =====
|
||||||
@@ -58,13 +58,6 @@ MT_CPS_DEFAULT_SID=sgbjia
|
|||||||
# 线上国内服务器留空(=直连)。留空且本机直连失败时 /feed、/coupons、/top-sales 会返回空。
|
# 线上国内服务器留空(=直连)。留空且本机直连失败时 /feed、/coupons、/top-sales 会返回空。
|
||||||
MT_CPS_PROXY=
|
MT_CPS_PROXY=
|
||||||
|
|
||||||
# ===== 京东联盟 CPS =====
|
|
||||||
# 京东联盟/京东宙斯开放平台创建应用后填写。AUTH_KEY 是工具商授权 key,自有应用可留空。
|
|
||||||
JD_UNION_APP_KEY=
|
|
||||||
JD_UNION_APP_SECRET=
|
|
||||||
JD_UNION_SITE_ID=
|
|
||||||
JD_UNION_AUTH_KEY=
|
|
||||||
|
|
||||||
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
|
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
|
||||||
# 客户端调本服务的 /api/v1/coupon/step 等,我们透传到 pricebot-backend。
|
# 客户端调本服务的 /api/v1/coupon/step 等,我们透传到 pricebot-backend。
|
||||||
# 本地开发用 localhost:8000。生产部署改成内网地址(如 http://pricebot.internal:8000)。
|
# 本地开发用 localhost:8000。生产部署改成内网地址(如 http://pricebot.internal:8000)。
|
||||||
@@ -124,16 +117,3 @@ PANGLE_REWARD_SECRET=
|
|||||||
# ⚠️ 仅本地联调:true 时开放 POST /api/v1/ad/test-grant,让 debug 客户端看完广告直接发奖,
|
# ⚠️ 仅本地联调:true 时开放 POST /api/v1/ad/test-grant,让 debug 客户端看完广告直接发奖,
|
||||||
# 验证"看广告→金币到账"全链路(未部署公网、穿山甲 S2S 打不到本地时用)。生产必须 false(绕过反作弊)。
|
# 验证"看广告→金币到账"全链路(未部署公网、穿山甲 S2S 打不到本地时用)。生产必须 false(绕过反作弊)。
|
||||||
AD_REWARD_TEST_GRANT_ENABLED=false
|
AD_REWARD_TEST_GRANT_ENABLED=false
|
||||||
|
|
||||||
# ===== 穿山甲 GroMore 数据 API(按天拉收益报表,供后台广告收益报表的「穿山甲后台收益」)=====
|
|
||||||
# ⚠️ 与上面发奖回调的 m-key 是【两套不同凭证】:这三样在穿山甲后台「接入中心 → GroMore-API →
|
|
||||||
# 聚合数据报告 API」文档页领取。只读拉取 GroMore 天级 revenue(预估)/ api_revenue(收益Api),
|
|
||||||
# 不参与发奖。三样齐全才生效;留空 = scripts/sync_pangle_revenue 直接 no-op。
|
|
||||||
# 子账号(role_id≠user_id)需主账号在「角色管理」授予「查看全部数据」权限,否则查不到收益(接口 118);
|
|
||||||
# role_id 填成 = user_id 即查主账号数据。同步:线上每天 ~10:30 由 timer 跑 python -m scripts.sync_pangle_revenue。
|
|
||||||
PANGLE_REPORT_USER_ID=0
|
|
||||||
PANGLE_REPORT_ROLE_ID=0
|
|
||||||
PANGLE_REPORT_SECURITY_KEY=
|
|
||||||
# GroMore AppId(报表 site_id 维度)→ 应用环境;默认取现网两个应用,按需覆盖。
|
|
||||||
PANGLE_REPORT_SITE_ID_PROD=5830519
|
|
||||||
PANGLE_REPORT_SITE_ID_TEST=5832303
|
|
||||||
|
|||||||
+2
-5
@@ -23,17 +23,14 @@ dist/
|
|||||||
*.db-journal
|
*.db-journal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
# data/ 整体不入库(运行时数据/上传文件/大二进制)。例外:邀请落地页 dl.html 及其
|
# data/ 整体不入库(运行时数据/上传文件/大二进制)。例外:邀请落地页 dl.html——
|
||||||
# 引用的静态插画(coupon-page-bg.png 底图 + sb-brand.png logo)——既是生产落地页资产
|
# 它既是生产落地页又是本地测试资产,纳入 git 便于同事一致测试
|
||||||
# 又是本地测试资产,纳入 git 便于同事一致测试、且随部署进生产 /media(否则线上 404→落地页毛坯)。
|
|
||||||
# (见 docs/邀请功能-实现原理与本地测试.md)。其余(avatars/ / *.apk / app.db 等)仍忽略。
|
# (见 docs/邀请功能-实现原理与本地测试.md)。其余(avatars/ / *.apk / app.db 等)仍忽略。
|
||||||
data/*
|
data/*
|
||||||
!data/media/
|
!data/media/
|
||||||
data/media/*
|
data/media/*
|
||||||
!data/media/dl.html
|
!data/media/dl.html
|
||||||
!data/media/taobao_landing.jpg
|
!data/media/taobao_landing.jpg
|
||||||
!data/media/coupon-page-bg.png
|
|
||||||
!data/media/sb-brand.png
|
|
||||||
|
|
||||||
secrets/*
|
secrets/*
|
||||||
!secrets/.gitkeep
|
!secrets/.gitkeep
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
"""merge jd_cps_order_fields and coupon_session_origin_package heads
|
|
||||||
|
|
||||||
Revision ID: 761ef181ce7c
|
|
||||||
Revises: coupon_session_origin_package, jd_cps_order_fields
|
|
||||||
Create Date: 2026-07-01 13:52:16.068808
|
|
||||||
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = '761ef181ce7c'
|
|
||||||
down_revision: Union[str, Sequence[str], None] = ('coupon_session_origin_package', 'jd_cps_order_fields')
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
pass
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
"""ad_pangle_daily_revenue: 穿山甲 GroMore 天级收益报表(后台结算口径)
|
|
||||||
|
|
||||||
新建表存放从 GroMore 数据 API 按天拉取的收益(revenue 预估 + api_revenue 收益Api),
|
|
||||||
粒度 = 日期 × 应用(app_env) × 代码位(our_code_id) × 广告源(adn)。供广告收益报表的
|
|
||||||
汇总/趋势级展示「穿山甲后台收益」,与客户端自报 eCPM 折算的预估互为对照。
|
|
||||||
|
|
||||||
Revision ID: ad_pangle_daily_revenue
|
|
||||||
Revises: 7db22acee504
|
|
||||||
Create Date: 2026-06-28
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
revision = "ad_pangle_daily_revenue"
|
|
||||||
down_revision = "7db22acee504"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.create_table(
|
|
||||||
"ad_pangle_daily_revenue",
|
|
||||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
|
||||||
sa.Column("report_date", sa.String(length=10), nullable=False),
|
|
||||||
sa.Column("app_env", sa.String(length=16), nullable=False),
|
|
||||||
sa.Column("site_id", sa.String(length=32), nullable=True),
|
|
||||||
sa.Column("our_code_id", sa.String(length=64), nullable=False),
|
|
||||||
sa.Column("adn", sa.String(length=16), nullable=False, server_default=""),
|
|
||||||
sa.Column("revenue_yuan", sa.Float(), nullable=False, server_default="0"),
|
|
||||||
sa.Column("api_revenue_yuan", sa.Float(), nullable=True),
|
|
||||||
sa.Column("ecpm", sa.String(length=32), nullable=True),
|
|
||||||
sa.Column("impressions", sa.Integer(), nullable=False, server_default="0"),
|
|
||||||
sa.Column("currency", sa.String(length=8), nullable=False, server_default="cny"),
|
|
||||||
sa.Column(
|
|
||||||
"synced_at",
|
|
||||||
sa.DateTime(timezone=True),
|
|
||||||
server_default=sa.func.now(),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
sa.UniqueConstraint(
|
|
||||||
"report_date", "app_env", "our_code_id", "adn", name="uq_ad_pangle_daily"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
"ix_ad_pangle_daily_revenue_report_date",
|
|
||||||
"ad_pangle_daily_revenue",
|
|
||||||
["report_date"],
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
"ix_ad_pangle_daily_revenue_our_code_id",
|
|
||||||
"ad_pangle_daily_revenue",
|
|
||||||
["our_code_id"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_index("ix_ad_pangle_daily_revenue_our_code_id", table_name="ad_pangle_daily_revenue")
|
|
||||||
op.drop_index("ix_ad_pangle_daily_revenue_report_date", table_name="ad_pangle_daily_revenue")
|
|
||||||
op.drop_table("ad_pangle_daily_revenue")
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
"""coupon_session 加 origin_package 列(发起来源 App 包名 → admin「发起平台」)
|
|
||||||
|
|
||||||
null=App 内(傻瓜比价首页)发起,非空=从美团/淘宝/京东弹券发起。与 trace_url 同理单独成迁移,
|
|
||||||
已建表环境靠它补列、全新环境顺序应用,不重复加列。
|
|
||||||
|
|
||||||
Revision ID: coupon_session_origin_package
|
|
||||||
Revises: coupon_session_trace_url
|
|
||||||
Create Date: 2026-06-30 00:00:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
from collections.abc import Sequence
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = "coupon_session_origin_package"
|
|
||||||
down_revision: str | Sequence[str] | None = "coupon_session_trace_url"
|
|
||||||
branch_labels: str | Sequence[str] | None = None
|
|
||||||
depends_on: str | Sequence[str] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
|
|
||||||
batch_op.add_column(sa.Column("origin_package", sa.String(length=64), nullable=True))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
|
|
||||||
batch_op.drop_column("origin_package")
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
"""coupon session table(领券任务全程流水 → admin「领券数据」看板数据源)
|
|
||||||
|
|
||||||
一次领券一行(trace_id 唯一):客户端 POST /api/v1/coupon/session 两段上报 —— 发起建行
|
|
||||||
(status=started)、收尾(completed/failed/abandoned)按 trace_id 更新同一行。记全程耗时
|
|
||||||
elapsed_ms + 各平台耗时 platform_elapsed + 机型/ROM,供 admin 算发起/完成数、耗时分位、机型维度。
|
|
||||||
|
|
||||||
Revision ID: coupon_session_table
|
|
||||||
Revises: feedback_submit_env
|
|
||||||
Create Date: 2026-06-30 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_table"
|
|
||||||
down_revision: str | Sequence[str] | None = "feedback_submit_env"
|
|
||||||
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:
|
|
||||||
op.create_table(
|
|
||||||
"coupon_session",
|
|
||||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
|
||||||
sa.Column("trace_id", sa.String(length=64), nullable=False),
|
|
||||||
sa.Column("device_id", sa.String(length=64), nullable=False),
|
|
||||||
sa.Column("user_id", sa.Integer(), nullable=True),
|
|
||||||
sa.Column("status", sa.String(length=16), nullable=False),
|
|
||||||
sa.Column("app_env", sa.String(length=16), nullable=True),
|
|
||||||
sa.Column("platforms", _JSON, nullable=True),
|
|
||||||
sa.Column("device_model", sa.String(length=128), nullable=True),
|
|
||||||
sa.Column("rom", sa.String(length=64), nullable=True),
|
|
||||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
|
|
||||||
sa.Column("started_date", sa.Date(), nullable=False),
|
|
||||||
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column("elapsed_ms", sa.Integer(), nullable=True),
|
|
||||||
sa.Column("platform_elapsed", _JSON, nullable=True),
|
|
||||||
sa.Column("claimed_count", sa.Integer(), nullable=True),
|
|
||||||
sa.Column(
|
|
||||||
"created_at",
|
|
||||||
sa.DateTime(timezone=True),
|
|
||||||
server_default=sa.text("(CURRENT_TIMESTAMP)"),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
sa.Column(
|
|
||||||
"updated_at",
|
|
||||||
sa.DateTime(timezone=True),
|
|
||||||
server_default=sa.text("(CURRENT_TIMESTAMP)"),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
sa.PrimaryKeyConstraint("id"),
|
|
||||||
sa.UniqueConstraint("trace_id", name="uq_coupon_session_trace"),
|
|
||||||
)
|
|
||||||
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
|
|
||||||
batch_op.create_index(
|
|
||||||
batch_op.f("ix_coupon_session_user_id"), ["user_id"], unique=False
|
|
||||||
)
|
|
||||||
batch_op.create_index(
|
|
||||||
batch_op.f("ix_coupon_session_app_env"), ["app_env"], unique=False
|
|
||||||
)
|
|
||||||
# admin 主聚合/筛选:按上海自然日 + 环境。
|
|
||||||
batch_op.create_index(
|
|
||||||
"ix_coupon_session_date_env", ["started_date", "app_env"], unique=False
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
|
|
||||||
batch_op.drop_index("ix_coupon_session_date_env")
|
|
||||||
batch_op.drop_index(batch_op.f("ix_coupon_session_app_env"))
|
|
||||||
batch_op.drop_index(batch_op.f("ix_coupon_session_user_id"))
|
|
||||||
op.drop_table("coupon_session")
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
"""coupon_session 加 trace_url 列(pricebot done 帧公网调试链接)
|
|
||||||
|
|
||||||
建表迁移 coupon_session_table 落地后才追加本列,故单独成一个迁移:已建表的环境(本地/已 upgrade 过)
|
|
||||||
靠它补列,全新环境则「建表(无 trace_url)→ 本迁移加列」,两条路一致、不重复加列。
|
|
||||||
|
|
||||||
Revision ID: coupon_session_trace_url
|
|
||||||
Revises: coupon_session_table
|
|
||||||
Create Date: 2026-06-30 00:00:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
from collections.abc import Sequence
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = "coupon_session_trace_url"
|
|
||||||
down_revision: str | Sequence[str] | None = "coupon_session_table"
|
|
||||||
branch_labels: str | Sequence[str] | None = None
|
|
||||||
depends_on: str | Sequence[str] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
|
|
||||||
batch_op.add_column(sa.Column("trace_url", sa.String(length=512), nullable=True))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
|
|
||||||
batch_op.drop_column("trace_url")
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
"""feedback 加提交端环境快照(app_version / device_model / rom_name / android_version)
|
|
||||||
|
|
||||||
Revision ID: feedback_submit_env
|
|
||||||
Revises: ad_pangle_daily_revenue
|
|
||||||
Create Date: 2026-06-29 00:00:00.000000
|
|
||||||
|
|
||||||
admin 用户反馈页要展示「提交版本号」「机型OS版本」,需在提交时落库端环境。仅新增可空列,
|
|
||||||
SQLite 原生支持 add_column、不用 batch;downgrade 的 drop_column 在 SQLite 走 batch 兜底。
|
|
||||||
历史反馈无此快照 → 留 NULL(无法回填);客户端改版带上后的新反馈才有值。
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision: str = "feedback_submit_env"
|
|
||||||
down_revision: Union[str, Sequence[str], None] = "ad_pangle_daily_revenue"
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.add_column("feedback", sa.Column("app_version", sa.String(length=32), nullable=True))
|
|
||||||
op.add_column("feedback", sa.Column("device_model", sa.String(length=64), nullable=True))
|
|
||||||
op.add_column("feedback", sa.Column("rom_name", sa.String(length=32), nullable=True))
|
|
||||||
op.add_column("feedback", sa.Column("android_version", sa.String(length=16), nullable=True))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
with op.batch_alter_table("feedback") as batch_op:
|
|
||||||
batch_op.drop_column("android_version")
|
|
||||||
batch_op.drop_column("rom_name")
|
|
||||||
batch_op.drop_column("device_model")
|
|
||||||
batch_op.drop_column("app_version")
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
"""feedback 加反馈来源/场景/运营回复(source / scene / admin_reply)
|
|
||||||
|
|
||||||
admin「用户反馈」页要展示并筛选「反馈类型」(比价反馈 / 普通反馈),并支持审核时给用户留言:
|
|
||||||
- source: 反馈来源入口(profile=「我的」页 / comparison=比价结果页)。NOT NULL,
|
|
||||||
旧数据 + 普通反馈默认 profile(server_default)。加索引供 admin 按类型筛选。
|
|
||||||
- scene: 比价反馈的问题场景(找错商品/优惠不对…),普通反馈为 NULL。
|
|
||||||
- admin_reply: 运营给用户的回复留言(用户端可见),随「我的反馈」历史下发。
|
|
||||||
|
|
||||||
均为新增列(SQLite 原生支持 add_column);downgrade 的 drop_column 在 SQLite 走 batch 兜底。
|
|
||||||
|
|
||||||
Revision ID: feedback_type_reply
|
|
||||||
Revises: 761ef181ce7c
|
|
||||||
Create Date: 2026-07-02 00:00:00.000000
|
|
||||||
"""
|
|
||||||
from collections.abc import Sequence
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = "feedback_type_reply"
|
|
||||||
down_revision: str | Sequence[str] | None = "761ef181ce7c"
|
|
||||||
branch_labels: str | Sequence[str] | None = None
|
|
||||||
depends_on: str | Sequence[str] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.add_column(
|
|
||||||
"feedback",
|
|
||||||
sa.Column(
|
|
||||||
"source",
|
|
||||||
sa.String(length=16),
|
|
||||||
nullable=False,
|
|
||||||
server_default="profile",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
op.add_column("feedback", sa.Column("scene", sa.String(length=32), nullable=True))
|
|
||||||
op.add_column("feedback", sa.Column("admin_reply", sa.String(length=256), nullable=True))
|
|
||||||
op.create_index("ix_feedback_source", "feedback", ["source"])
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
with op.batch_alter_table("feedback") as batch_op:
|
|
||||||
batch_op.drop_index("ix_feedback_source")
|
|
||||||
batch_op.drop_column("admin_reply")
|
|
||||||
batch_op.drop_column("scene")
|
|
||||||
batch_op.drop_column("source")
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
"""add jd cps order fields
|
|
||||||
|
|
||||||
Revision ID: jd_cps_order_fields
|
|
||||||
Revises: 7db22acee504
|
|
||||||
Create Date: 2026-06-28 20:30:00.000000
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
revision = "jd_cps_order_fields"
|
|
||||||
down_revision = "7db22acee504"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
with op.batch_alter_table("cps_order") as batch_op:
|
|
||||||
batch_op.add_column(
|
|
||||||
sa.Column("platform", sa.String(length=20), nullable=False, server_default="meituan")
|
|
||||||
)
|
|
||||||
batch_op.add_column(sa.Column("external_order_id", sa.String(length=128), nullable=True))
|
|
||||||
batch_op.add_column(sa.Column("external_row_id", sa.String(length=128), nullable=True))
|
|
||||||
batch_op.add_column(sa.Column("estimated_commission_cents", sa.Integer(), nullable=True))
|
|
||||||
batch_op.add_column(sa.Column("actual_commission_cents", sa.Integer(), nullable=True))
|
|
||||||
batch_op.add_column(sa.Column("jd_valid_code", sa.String(length=16), nullable=True))
|
|
||||||
batch_op.add_column(sa.Column("settle_month", sa.String(length=16), nullable=True))
|
|
||||||
batch_op.add_column(sa.Column("site_id", sa.String(length=128), nullable=True))
|
|
||||||
batch_op.add_column(sa.Column("position_id", sa.String(length=128), nullable=True))
|
|
||||||
batch_op.add_column(sa.Column("pid", sa.String(length=128), nullable=True))
|
|
||||||
batch_op.add_column(sa.Column("sub_union_id", sa.String(length=128), nullable=True))
|
|
||||||
batch_op.create_index("ix_cps_order_platform", ["platform"])
|
|
||||||
batch_op.create_index("ix_cps_order_external_order_id", ["external_order_id"])
|
|
||||||
batch_op.create_index("ix_cps_order_external_row_id", ["external_row_id"])
|
|
||||||
batch_op.create_index("ix_cps_order_jd_valid_code", ["jd_valid_code"])
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
with op.batch_alter_table("cps_order") as batch_op:
|
|
||||||
batch_op.drop_index("ix_cps_order_jd_valid_code")
|
|
||||||
batch_op.drop_index("ix_cps_order_external_row_id")
|
|
||||||
batch_op.drop_index("ix_cps_order_external_order_id")
|
|
||||||
batch_op.drop_index("ix_cps_order_platform")
|
|
||||||
batch_op.drop_column("sub_union_id")
|
|
||||||
batch_op.drop_column("pid")
|
|
||||||
batch_op.drop_column("position_id")
|
|
||||||
batch_op.drop_column("site_id")
|
|
||||||
batch_op.drop_column("settle_month")
|
|
||||||
batch_op.drop_column("jd_valid_code")
|
|
||||||
batch_op.drop_column("actual_commission_cents")
|
|
||||||
batch_op.drop_column("estimated_commission_cents")
|
|
||||||
batch_op.drop_column("external_row_id")
|
|
||||||
batch_op.drop_column("external_order_id")
|
|
||||||
batch_op.drop_column("platform")
|
|
||||||
@@ -21,7 +21,6 @@ from app.admin.routers.audit import router as audit_router
|
|||||||
from app.admin.routers.auth import router as auth_router
|
from app.admin.routers.auth import router as auth_router
|
||||||
from app.admin.routers.comparison import router as comparison_router
|
from app.admin.routers.comparison import router as comparison_router
|
||||||
from app.admin.routers.config import router as config_router
|
from app.admin.routers.config import router as config_router
|
||||||
from app.admin.routers.coupon_data import router as coupon_data_router
|
|
||||||
from app.admin.routers.cps import router as cps_router
|
from app.admin.routers.cps import router as cps_router
|
||||||
from app.admin.routers.dashboard import router as dashboard_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.device_liveness import router as device_liveness_router
|
||||||
@@ -102,7 +101,6 @@ admin_app.include_router(audit_router)
|
|||||||
admin_app.include_router(config_router)
|
admin_app.include_router(config_router)
|
||||||
admin_app.include_router(comparison_router)
|
admin_app.include_router(comparison_router)
|
||||||
admin_app.include_router(cps_router)
|
admin_app.include_router(cps_router)
|
||||||
admin_app.include_router(coupon_data_router)
|
|
||||||
admin_app.include_router(ad_audit_router)
|
admin_app.include_router(ad_audit_router)
|
||||||
admin_app.include_router(ad_config_router)
|
admin_app.include_router(ad_config_router)
|
||||||
admin_app.include_router(ad_revenue_router)
|
admin_app.include_router(ad_revenue_router)
|
||||||
|
|||||||
@@ -136,16 +136,12 @@ def _feed_scene_matches(rec: AdFeedRewardRecord, scene: str | None) -> bool:
|
|||||||
"""该信息流记录是否落入请求的展示筛选 scene。
|
"""该信息流记录是否落入请求的展示筛选 scene。
|
||||||
- scene=="feed":ad_type in ("feed", NULL)(旧数据 NULL 视为 feed,向后兼容)
|
- scene=="feed":ad_type in ("feed", NULL)(旧数据 NULL 视为 feed,向后兼容)
|
||||||
- scene=="draw":ad_type=="draw"
|
- scene=="draw":ad_type=="draw"
|
||||||
- scene=="feed_all":所有信息流(feed/draw/NULL 都要)——业务已全切 Draw 信息流,收益报表把「Draw 信息流」
|
|
||||||
当作整个信息流口径(含历史误标 feed/NULL),用它避免筛选漏历史。
|
|
||||||
- scene 为 None:不筛(两类都要)。
|
- scene 为 None:不筛(两类都要)。
|
||||||
"""
|
"""
|
||||||
if scene == "feed":
|
if scene == "feed":
|
||||||
return rec.ad_type in (None, "feed")
|
return rec.ad_type in (None, "feed")
|
||||||
if scene == "draw":
|
if scene == "draw":
|
||||||
return rec.ad_type == "draw"
|
return rec.ad_type == "draw"
|
||||||
if scene == "feed_all":
|
|
||||||
return True
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -188,7 +184,6 @@ def _feed_rows(
|
|||||||
"record_id": rec.id,
|
"record_id": rec.id,
|
||||||
"user_id": rec.user_id,
|
"user_id": rec.user_id,
|
||||||
"ad_session_id": rec.ad_session_id,
|
"ad_session_id": rec.ad_session_id,
|
||||||
"trace_id": rec.trace_id,
|
|
||||||
"app_env": rec.app_env,
|
"app_env": rec.app_env,
|
||||||
"our_code_id": rec.our_code_id,
|
"our_code_id": rec.our_code_id,
|
||||||
"created_at": rec.created_at,
|
"created_at": rec.created_at,
|
||||||
@@ -214,7 +209,6 @@ def _feed_rows(
|
|||||||
"record_id": rec.id,
|
"record_id": rec.id,
|
||||||
"user_id": rec.user_id,
|
"user_id": rec.user_id,
|
||||||
"ad_session_id": rec.ad_session_id,
|
"ad_session_id": rec.ad_session_id,
|
||||||
"trace_id": rec.trace_id,
|
|
||||||
"app_env": rec.app_env,
|
"app_env": rec.app_env,
|
||||||
"our_code_id": rec.our_code_id,
|
"our_code_id": rec.our_code_id,
|
||||||
"created_at": rec.created_at,
|
"created_at": rec.created_at,
|
||||||
@@ -247,7 +241,7 @@ def audit_rows(
|
|||||||
rows: list[dict] = []
|
rows: list[dict] = []
|
||||||
if scene in (None, "reward_video"):
|
if scene in (None, "reward_video"):
|
||||||
rows.extend(_reward_video_rows(db, date=date, user_id=user_id))
|
rows.extend(_reward_video_rows(db, date=date, user_id=user_id))
|
||||||
if scene in (None, "feed", "draw", "feed_all"):
|
if scene in (None, "feed", "draw"):
|
||||||
rows.extend(_feed_rows(db, date=date, user_id=user_id, scene=scene))
|
rows.extend(_feed_rows(db, date=date, user_id=user_id, scene=scene))
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,9 @@
|
|||||||
只读。每行 = 一次广告事件(不再按用户聚合):
|
只读。每行 = 一次广告事件(不再按用户聚合):
|
||||||
- **激励视频**:一次观看 = 1 条展示(ad_ecpm)+ 1 条发奖(ad_reward),按 ad_session_id 合并成一行,
|
- **激励视频**:一次观看 = 1 条展示(ad_ecpm)+ 1 条发奖(ad_reward),按 ad_session_id 合并成一行,
|
||||||
直接给出 eCPM / 收益 + 状态 / 应发 / 实发 / 一致;点开看该条金币复算因子。
|
直接给出 eCPM / 收益 + 状态 / 应发 / 实发 / 一致;点开看该条金币复算因子。
|
||||||
- **信息流(比价/领券)**:一次比价 / 一次领券 = 一条整场发奖(ad_feed_reward)一行,给出 eCPM /
|
- **信息流**:轮播每条展示各一行(impressionId 各自独立);整场发奖(ad_feed_reward,client_event_id)
|
||||||
发奖金币 + 应发 / 实发 / 一致;点开看金币复算因子。⚠️ draw 的逐条展示(ad_ecpm,impressionId 各自
|
与逐条展示无法对应,单独成「纯发奖」行。
|
||||||
独立、与整场发奖无公共键、无法归到「哪一次」)**不再单独占行**(2026-07 按「一次比价/领券放一块」调整)——
|
- 兜底:有展示无发奖(中途关 / 未达发奖)、有发奖无展示(未上报 eCPM)都各自成行。
|
||||||
其展示数 / eCPM / 预估收益仍进全量统计(合计 / 趋势 / 分类大盘 / 穿山甲对照),只是主表不逐条铺开。
|
|
||||||
- 兜底:激励视频有展示无发奖(中途关 / 未达发奖)、有发奖无展示(未上报 eCPM)仍各自成行。
|
|
||||||
|
|
||||||
展示与收益来自 ad_ecpm_record(收益 = eCPM元 ÷ 1000);应发 / 实发金币复用金币审计逐条复算
|
展示与收益来自 ad_ecpm_record(收益 = eCPM元 ÷ 1000);应发 / 实发金币复用金币审计逐条复算
|
||||||
(ad_audit.audit_rows,与正式发奖同一公式口径,不另写公式)。合计与对账在全量上统计,
|
(ad_audit.audit_rows,与正式发奖同一公式口径,不另写公式)。合计与对账在全量上统计,
|
||||||
@@ -33,7 +31,6 @@ from app.admin.repositories import stats as admin_stats
|
|||||||
from app.core import rewards
|
from app.core import rewards
|
||||||
from app.models.ad_ecpm import AdEcpmRecord
|
from app.models.ad_ecpm import AdEcpmRecord
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.repositories import ad_pangle_revenue
|
|
||||||
|
|
||||||
|
|
||||||
def _cn_hour(dt: datetime) -> int:
|
def _cn_hour(dt: datetime) -> int:
|
||||||
@@ -60,6 +57,14 @@ def _date_range(date_from: str, date_to: str) -> list[str]:
|
|||||||
_AUDIT_SCENES = {"reward_video", "feed", "draw"}
|
_AUDIT_SCENES = {"reward_video", "feed", "draw"}
|
||||||
|
|
||||||
|
|
||||||
|
def _event_ad_type(row: dict) -> str:
|
||||||
|
"""纯发奖事件行的 ad_type:信息流行用 audit 带回的真实 ad_type(feed/draw),回退 feed;
|
||||||
|
激励视频行恒 reward_video。不再用 scene 硬映射,避免把 draw 丢成 feed。"""
|
||||||
|
if row["scene"] == "reward_video":
|
||||||
|
return "reward_video"
|
||||||
|
return row.get("ad_type") or "feed"
|
||||||
|
|
||||||
|
|
||||||
# 发奖复算明细字段(展开下钻看「金币怎么算出来的」)——从 audit 行原样取这些 key。
|
# 发奖复算明细字段(展开下钻看「金币怎么算出来的」)——从 audit 行原样取这些 key。
|
||||||
_REWARD_DETAIL_KEYS = (
|
_REWARD_DETAIL_KEYS = (
|
||||||
"record_id", "created_at", "status", "ecpm", "ecpm_factor", "units",
|
"record_id", "created_at", "status", "ecpm", "ecpm_factor", "units",
|
||||||
@@ -81,7 +86,6 @@ def ad_revenue_report(
|
|||||||
user_id: int | None = None,
|
user_id: int | None = None,
|
||||||
ad_type: str | None = None,
|
ad_type: str | None = None,
|
||||||
feed_scene: str | None = None,
|
feed_scene: str | None = None,
|
||||||
app_env: str | None = None,
|
|
||||||
granularity: str = "day",
|
granularity: str = "day",
|
||||||
limit: int = 500,
|
limit: int = 500,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
@@ -102,14 +106,9 @@ def ad_revenue_report(
|
|||||||
# 同时保留全量列表,未被展示合并的成「纯发奖」事件。
|
# 同时保留全量列表,未被展示合并的成「纯发奖」事件。
|
||||||
reward_by_session: dict[tuple[int, str], list[dict]] = {}
|
reward_by_session: dict[tuple[int, str], list[dict]] = {}
|
||||||
all_reward_rows: list[dict] = []
|
all_reward_rows: list[dict] = []
|
||||||
# 报表 ad_type → audit scene:reward_video/feed 直传;**draw(前端「Draw 信息流」)映射成 feed_all**
|
# 报表 ad_type 直接当 audit scene 用(取值一致);未知/无效 ad_type 不取发奖行。draw 在此被
|
||||||
# ——业务已全切 Draw,把「Draw 信息流」当作整个信息流口径(含历史误标 feed/NULL),否则筛选会漏历史。
|
# 正确传成 scene="draw",audit 会按 ad_type 筛出 Draw 发奖,不再丢成 feed。
|
||||||
if ad_type == "draw":
|
audit_scene = ad_type if ad_type in _AUDIT_SCENES else None
|
||||||
audit_scene = "feed_all"
|
|
||||||
elif ad_type in _AUDIT_SCENES:
|
|
||||||
audit_scene = ad_type
|
|
||||||
else:
|
|
||||||
audit_scene = None
|
|
||||||
if ad_type is None or audit_scene is not None:
|
if ad_type is None or audit_scene is not None:
|
||||||
for d in _date_range(date_from, date_to):
|
for d in _date_range(date_from, date_to):
|
||||||
for row in ad_audit.audit_rows(db, date=d, user_id=user_id, scene=audit_scene):
|
for row in ad_audit.audit_rows(db, date=d, user_id=user_id, scene=audit_scene):
|
||||||
@@ -139,10 +138,7 @@ def ad_revenue_report(
|
|||||||
)
|
)
|
||||||
if user_id is not None:
|
if user_id is not None:
|
||||||
stmt = stmt.where(AdEcpmRecord.user_id == user_id)
|
stmt = stmt.where(AdEcpmRecord.user_id == user_id)
|
||||||
if ad_type == "draw":
|
if ad_type is not None:
|
||||||
# draw = 所有信息流展示(业务已全 Draw,含历史误标 feed);展示行只进统计,不占主表行
|
|
||||||
stmt = stmt.where(AdEcpmRecord.ad_type.in_(["draw", "feed"]))
|
|
||||||
elif ad_type is not None:
|
|
||||||
stmt = stmt.where(AdEcpmRecord.ad_type == ad_type)
|
stmt = stmt.where(AdEcpmRecord.ad_type == ad_type)
|
||||||
for rec in db.execute(stmt).scalars():
|
for rec in db.execute(stmt).scalars():
|
||||||
rwd = _pop_reward(rec.user_id, rec.ad_session_id)
|
rwd = _pop_reward(rec.user_id, rec.ad_session_id)
|
||||||
@@ -159,16 +155,10 @@ def ad_revenue_report(
|
|||||||
"has_impression": True,
|
"has_impression": True,
|
||||||
"impressions": 1,
|
"impressions": 1,
|
||||||
"ecpm": rec.ecpm_raw,
|
"ecpm": rec.ecpm_raw,
|
||||||
# 单次展示收益(元)= eCPM元 ÷ 1000(每千次→单次)。eCPM 先钳到 AD_ECPM_MAX_FEN(¥500 CPM)
|
# 单次展示收益(元)= eCPM元 ÷ 1000(每千次→单次);与发奖同源解析,口径一致。
|
||||||
# 再折收益,与发奖口径 [rewards.calculate_ad_reward_coin] 一致(2026-06-29 修:原裸 parse_ecpm_yuan
|
"revenue_yuan": round(rewards.parse_ecpm_yuan(rec.ecpm_raw) / 1000.0, 6),
|
||||||
# 不钳,伪造/异常天价 eCPM 会把报表预估收益冲到任意大;金币侧已钳、收益侧漏钳)。
|
|
||||||
"revenue_yuan": round(
|
|
||||||
min(rewards.parse_ecpm_yuan(rec.ecpm_raw), rewards.AD_ECPM_MAX_FEN / 100.0) / 1000.0, 6,
|
|
||||||
),
|
|
||||||
"adn": rec.adn,
|
"adn": rec.adn,
|
||||||
"slot_id": rec.slot_id,
|
"slot_id": rec.slot_id,
|
||||||
"sub_rewards": [],
|
|
||||||
"sub_count": 1,
|
|
||||||
}
|
}
|
||||||
if rwd is not None:
|
if rwd is not None:
|
||||||
ev.update({
|
ev.update({
|
||||||
@@ -188,88 +178,33 @@ def ad_revenue_report(
|
|||||||
})
|
})
|
||||||
events.append(ev)
|
events.append(ev)
|
||||||
|
|
||||||
# 3) 未被展示合并的发奖行 → 事件:
|
# 3) 未被展示合并的发奖行 → 「纯发奖」事件(信息流整场发奖 / 有发奖无展示)。
|
||||||
# - 激励视频(reward_video):逐条成「纯发奖」事件(每次一个 ad_session_id;有发奖无展示等)。
|
# 收益恒 0(收益只算展示侧,避免与展示行重复计)。
|
||||||
# - 信息流(feed/draw):同一次比价/领券的多条广告共享**整场 ad_session_id**(客户端整场复用),
|
|
||||||
# 按 (user_id, ad_session_id) 聚成**一次比价 / 一次领券**父事件;sub_rewards 为组内逐条明细,
|
|
||||||
# 应发/实发取组内合计;业务已全 Draw → 类型统一 "draw"。session 缺失(极少旧数据)各自单独成组。
|
|
||||||
feed_groups: dict[tuple[int, str], list[dict]] = {}
|
|
||||||
for row in all_reward_rows:
|
for row in all_reward_rows:
|
||||||
if row["record_id"] in used_reward_ids:
|
if row["record_id"] in used_reward_ids:
|
||||||
continue
|
continue
|
||||||
if row["scene"] == "reward_video":
|
|
||||||
events.append({
|
|
||||||
"event_key": f"rwd-{row['record_id']}",
|
|
||||||
"report_date": row["_report_date"],
|
|
||||||
"user_id": row["user_id"],
|
|
||||||
"ad_type": "reward_video",
|
|
||||||
"feed_scene": row.get("feed_scene"),
|
|
||||||
"app_env": row.get("app_env"),
|
|
||||||
"our_code_id": row.get("our_code_id"),
|
|
||||||
"created_at": row["created_at"],
|
|
||||||
"hour": _cn_hour(row["created_at"]) if by_hour else None,
|
|
||||||
"has_impression": False,
|
|
||||||
"impressions": 0,
|
|
||||||
"ecpm": row["ecpm"],
|
|
||||||
"revenue_yuan": 0.0,
|
|
||||||
"adn": None,
|
|
||||||
"slot_id": None,
|
|
||||||
"has_reward": True,
|
|
||||||
"status": row["status"],
|
|
||||||
"expected_coin": int(row["expected_coin"]),
|
|
||||||
"actual_coin": int(row["actual_coin"]),
|
|
||||||
"matched": bool(row["matched"]),
|
|
||||||
"reward_detail": _reward_detail(row),
|
|
||||||
"sub_rewards": [],
|
|
||||||
"sub_count": 1,
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
# 聚合单位 = 一次完整比价/领券流程:优先用 trace_id(比价带 comparisonTraceId、领券带 sessionTraceId,
|
|
||||||
# 整个流程不变;即使中途点广告致浮层关闭重弹、ad_session_id 变了,trace_id 仍不变 → 全流程聚成一行)。
|
|
||||||
# 无 trace_id(历史领券未上报 / 旧数据)回退整场 ad_session_id;再无则 record_id 各自成组、不误并。
|
|
||||||
grp_key = row.get("trace_id") or row.get("ad_session_id") or f"_rid-{row['record_id']}"
|
|
||||||
feed_groups.setdefault((row["user_id"], grp_key), []).append(row)
|
|
||||||
|
|
||||||
# 信息流分组 → 「一次比价 / 一次领券」父事件(收益恒 0:收益只算展示侧,避免与展示行重复计)。
|
|
||||||
for (uid, grp_key), group in feed_groups.items():
|
|
||||||
group.sort(key=lambda r: (r["created_at"], r["record_id"]))
|
|
||||||
rep = group[-1] # 代表条(最新一条):时间/场景/应用/代码位取它
|
|
||||||
expected_sum = sum(int(g["expected_coin"]) for g in group)
|
|
||||||
actual_sum = sum(int(g["actual_coin"]) for g in group)
|
|
||||||
# 父行 eCPM:组内各条 eCPM(分)均值(展示用,各条不同);无有效值则取代表条
|
|
||||||
ecpm_fens = [rewards.parse_ecpm_fen(g["ecpm"]) for g in group if g.get("ecpm")]
|
|
||||||
avg_ecpm = str(round(sum(ecpm_fens) / len(ecpm_fens))) if ecpm_fens else rep.get("ecpm")
|
|
||||||
# 主表逐行显示用:这次发奖广告的预估收益之和(发奖侧 eCPM 折算,钳顶同展示侧)。只放进
|
|
||||||
# row_revenue_yuan 给主表逐行展示,不进 revenue_yuan/合计/趋势——避免与展示侧 total 重复计。
|
|
||||||
row_revenue = round(sum(
|
|
||||||
min(rewards.parse_ecpm_yuan(g["ecpm"]), rewards.AD_ECPM_MAX_FEN / 100.0) / 1000.0
|
|
||||||
for g in group if g.get("ecpm")
|
|
||||||
), 6)
|
|
||||||
events.append({
|
events.append({
|
||||||
"event_key": f"feedgrp-{uid}-{grp_key}",
|
"event_key": f"rwd-{row['record_id']}",
|
||||||
"report_date": rep["_report_date"],
|
"report_date": row["_report_date"],
|
||||||
"user_id": uid,
|
"user_id": row["user_id"],
|
||||||
"ad_type": "draw", # 业务已全切 Draw 信息流,聚合行统一 draw
|
"ad_type": _event_ad_type(row),
|
||||||
"feed_scene": rep.get("feed_scene"),
|
"feed_scene": row.get("feed_scene"),
|
||||||
"app_env": rep.get("app_env"),
|
"app_env": row.get("app_env"),
|
||||||
"our_code_id": rep.get("our_code_id"),
|
"our_code_id": row.get("our_code_id"),
|
||||||
"created_at": rep["created_at"],
|
"created_at": row["created_at"],
|
||||||
"hour": _cn_hour(rep["created_at"]) if by_hour else None,
|
"hour": _cn_hour(row["created_at"]) if by_hour else None,
|
||||||
"has_impression": False,
|
"has_impression": False,
|
||||||
"impressions": 0,
|
"impressions": 0,
|
||||||
"ecpm": avg_ecpm,
|
"ecpm": row["ecpm"],
|
||||||
"revenue_yuan": 0.0,
|
"revenue_yuan": 0.0,
|
||||||
"row_revenue_yuan": row_revenue,
|
|
||||||
"adn": None,
|
"adn": None,
|
||||||
"slot_id": None,
|
"slot_id": None,
|
||||||
"has_reward": True,
|
"has_reward": True,
|
||||||
"status": rep["status"], # 代表状态(逐条见展开)
|
"status": row["status"],
|
||||||
"expected_coin": expected_sum,
|
"expected_coin": int(row["expected_coin"]),
|
||||||
"actual_coin": actual_sum,
|
"actual_coin": int(row["actual_coin"]),
|
||||||
"matched": all(bool(g["matched"]) for g in group),
|
"matched": bool(row["matched"]),
|
||||||
"reward_detail": None,
|
"reward_detail": _reward_detail(row),
|
||||||
"sub_rewards": [_reward_detail(g) for g in group],
|
|
||||||
"sub_count": len(group),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
# 「场景」作为全局筛选(与 user_id/ad_type 一致):同时作用于明细、合计与 daily/hourly 趋势。
|
# 「场景」作为全局筛选(与 user_id/ad_type 一致):同时作用于明细、合计与 daily/hourly 趋势。
|
||||||
@@ -277,14 +212,6 @@ def ad_revenue_report(
|
|||||||
if feed_scene is not None:
|
if feed_scene is not None:
|
||||||
events = [e for e in events if e.get("feed_scene") == feed_scene]
|
events = [e for e in events if e.get("feed_scene") == feed_scene]
|
||||||
|
|
||||||
# app_env 过滤(2026-06-29 新增能力,修隐患:测试应用上报的假 eCPM 如 ¥678 CPM 会污染正式收益合计/平均):
|
|
||||||
# 显式传 "prod"/"test" 只看该环境;不传=全部(维持现状)。**不擅自把默认改成排除 test**——本地 dev 库多为
|
|
||||||
# test 数据、默认排除会使本地报表空,且「正式报表是否含 test」属产品口径。建议前端报表页加 app_env 筛选器
|
|
||||||
# (默认选 prod),或产品确认后再把默认改成排除 test。注:穿山甲后台收益列(total_pangle_*)暂未联动此过滤
|
|
||||||
# (它是独立对照列,且 pangle 的 test 是真实小额、非客户端那种假值)。
|
|
||||||
if app_env is not None:
|
|
||||||
events = [e for e in events if e.get("app_env") == app_env]
|
|
||||||
|
|
||||||
# 排序:time=按时间倒序(新→旧);ecpm=按 eCPM 数值倒序(eCPM 原值是字符串「分」,转数值排;
|
# 排序:time=按时间倒序(新→旧);ecpm=按 eCPM 数值倒序(eCPM 原值是字符串「分」,转数值排;
|
||||||
# 纯发奖行用其发奖采用的 eCPM,缺失/非法计 0 排末尾)。
|
# 纯发奖行用其发奖采用的 eCPM,缺失/非法计 0 排末尾)。
|
||||||
if sort == "ecpm":
|
if sort == "ecpm":
|
||||||
@@ -328,25 +255,6 @@ def ad_revenue_report(
|
|||||||
for d in sorted(daily_map.values(), key=lambda x: x["date"])
|
for d in sorted(daily_map.values(), key=lambda x: x["date"])
|
||||||
]
|
]
|
||||||
|
|
||||||
# 穿山甲后台收益(GroMore 数据 API,T+1 入库 ad_pangle_daily_revenue):汇总 + 按天趋势级展示,
|
|
||||||
# 与上面客户端自报 eCPM 折算的预估并列对照(看 gap)。穿山甲数据**无用户/场景/类型维度**,故仅在
|
|
||||||
# 「全量视图」(未按 user_id / ad_type / feed_scene 过滤)给值;一旦带这些过滤,穿山甲数无法对应口径
|
|
||||||
# → 置 None,前端显示「-」并提示。逐条事件行不动(仍是客户端预估)。
|
|
||||||
pangle_filterable = user_id is None and ad_type is None and feed_scene is None
|
|
||||||
total_pangle_revenue_yuan: float | None = None
|
|
||||||
total_pangle_api_revenue_yuan: float | None = None
|
|
||||||
if pangle_filterable:
|
|
||||||
pangle_aggs = ad_pangle_revenue.aggregate_by_date(db, date_from=date_from, date_to=date_to)
|
|
||||||
if pangle_aggs:
|
|
||||||
by_date = {a["date"]: a for a in pangle_aggs}
|
|
||||||
for d in daily:
|
|
||||||
pa = by_date.get(d["date"])
|
|
||||||
d["pangle_revenue_yuan"] = pa["revenue_yuan"] if pa else None
|
|
||||||
d["pangle_api_revenue_yuan"] = pa["api_revenue_yuan"] if pa else None
|
|
||||||
total_pangle_revenue_yuan = round(sum(a["revenue_yuan"] for a in pangle_aggs), 6)
|
|
||||||
api_vals = [a["api_revenue_yuan"] for a in pangle_aggs if a["api_revenue_yuan"] is not None]
|
|
||||||
total_pangle_api_revenue_yuan = round(sum(api_vals), 6) if api_vals else None
|
|
||||||
|
|
||||||
# 按小时汇总(全量,不受分页 limit/offset 影响):供前端按小时趋势图(单日 granularity=hour 时用)。
|
# 按小时汇总(全量,不受分页 limit/offset 影响):供前端按小时趋势图(单日 granularity=hour 时用)。
|
||||||
# 只在 by_hour 下聚合(此时每个 event 带 hour);否则空。前端按天趋势仍用 daily。
|
# 只在 by_hour 下聚合(此时每个 event 带 hour);否则空。前端按天趋势仍用 daily。
|
||||||
hourly: list[dict] = []
|
hourly: list[dict] = []
|
||||||
@@ -390,26 +298,11 @@ def ad_revenue_report(
|
|||||||
is_today = date_from == date_to == rewards.cn_today().isoformat()
|
is_today = date_from == date_to == rewards.cn_today().isoformat()
|
||||||
dau = admin_stats.today_dau(db) if is_today else None
|
dau = admin_stats.today_dau(db) if is_today else None
|
||||||
|
|
||||||
# 主表「逐行」= 单次广告行为(2026-07 按「一次比价/领券放一块」聚合):激励视频 = 一次观看一行(展示+发奖
|
|
||||||
# 按 ad_session_id 合并);一次比价 / 一次领券 = 该次整场多条广告按 ad_session_id 聚成一行(展开看逐条)。
|
|
||||||
# 信息流(draw/feed)的逐条展示(ad_ecpm,impressionId 各自独立、与整场发奖无公共键)不再单独占行
|
|
||||||
# ——其展示数 / eCPM / 预估收益已计入上面的全量统计(total_*、daily / hourly、type_stats、穿山甲对照),
|
|
||||||
# 只是主表不逐条铺开;逐条明细在父行展开里看(sub_rewards)。合计 / 趋势 / 分类大盘均基于全量 events,
|
|
||||||
# 不受此过滤影响;total / 分页只作用于主表行。
|
|
||||||
main_rows = [
|
|
||||||
e for e in events
|
|
||||||
if not (e["ad_type"] in ("draw", "feed") and e["has_impression"] and not e["has_reward"])
|
|
||||||
]
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total": len(main_rows),
|
"total": len(events),
|
||||||
"truncated": len(main_rows) > offset + limit,
|
"truncated": len(events) > offset + limit,
|
||||||
"total_impressions": total_impressions,
|
"total_impressions": total_impressions,
|
||||||
"total_revenue_yuan": total_revenue_yuan,
|
"total_revenue_yuan": total_revenue_yuan,
|
||||||
# 穿山甲后台收益合计(元):预估 revenue + 收益Api;非全量视图(带 user/类型/场景过滤)或无数据为 None。
|
|
||||||
"total_pangle_revenue_yuan": total_pangle_revenue_yuan,
|
|
||||||
"total_pangle_api_revenue_yuan": total_pangle_api_revenue_yuan,
|
|
||||||
"pangle_revenue_available": total_pangle_revenue_yuan is not None,
|
|
||||||
"total_expected_coin": total_expected_coin,
|
"total_expected_coin": total_expected_coin,
|
||||||
"total_actual_coin": total_actual_coin,
|
"total_actual_coin": total_actual_coin,
|
||||||
"mismatch_count": mismatch_count,
|
"mismatch_count": mismatch_count,
|
||||||
@@ -417,5 +310,5 @@ def ad_revenue_report(
|
|||||||
"hourly": hourly,
|
"hourly": hourly,
|
||||||
"type_stats": type_stats,
|
"type_stats": type_stats,
|
||||||
"dau": dau,
|
"dau": dau,
|
||||||
"items": main_rows[offset:offset + limit],
|
"items": events[offset:offset + limit],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,225 +0,0 @@
|
|||||||
"""admin「领券数据」看板聚合:发起/完成数、耗时均值与分位、按天/小时趋势、逐条明细。
|
|
||||||
|
|
||||||
数据源 coupon_session(一次领券一行,客户端 /api/v1/coupon/session 两段上报)。量级不大,全量拉
|
|
||||||
区间数据后 Python 聚合(分位 SQLite 无 percentile,统一 Python 算,PG 上也一致)。
|
|
||||||
- 发起数 = 区间内全部 session(含 started/completed/failed/abandoned),= 流失统计的基数。
|
|
||||||
- 完成数 / 耗时均值 / 分位 = 仅 status==completed 子集(成功跑完才有可比的"领券耗时")。
|
|
||||||
- summary/daily/hourly/total 在全量上算,不受分页;items 为排序后当前页。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import UTC, date as _date, datetime
|
|
||||||
|
|
||||||
from sqlalchemy import func, or_, select
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.core import rewards
|
|
||||||
from app.models.coupon_state import CouponSession
|
|
||||||
from app.models.user import User
|
|
||||||
|
|
||||||
|
|
||||||
def _cn_hour(dt: datetime) -> int:
|
|
||||||
"""started_at(UTC 口径)→ 北京时间小时(0–23)。naive 当 UTC(sqlite),tz-aware 直接换算(pg)。"""
|
|
||||||
if dt.tzinfo is None:
|
|
||||||
dt = dt.replace(tzinfo=UTC)
|
|
||||||
return dt.astimezone(rewards.CN_TZ).hour
|
|
||||||
|
|
||||||
|
|
||||||
def _percentile(sorted_vals: list[int], q: float) -> int | None:
|
|
||||||
"""线性插值分位(q=0..100,numpy 默认法)。sorted_vals 须已升序;空返回 None。"""
|
|
||||||
if not sorted_vals:
|
|
||||||
return None
|
|
||||||
if len(sorted_vals) == 1:
|
|
||||||
return sorted_vals[0]
|
|
||||||
idx = (len(sorted_vals) - 1) * q / 100.0
|
|
||||||
lo = int(idx)
|
|
||||||
hi = min(lo + 1, len(sorted_vals) - 1)
|
|
||||||
frac = idx - lo
|
|
||||||
return round(sorted_vals[lo] * (1 - frac) + sorted_vals[hi] * frac)
|
|
||||||
|
|
||||||
|
|
||||||
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:
|
|
||||||
"""CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。"""
|
|
||||||
return {
|
|
||||||
"id": r.id,
|
|
||||||
"trace_id": r.trace_id,
|
|
||||||
"user_id": r.user_id,
|
|
||||||
"user_phone": phone,
|
|
||||||
"user_nickname": nickname,
|
|
||||||
"status": r.status,
|
|
||||||
"platforms": r.platforms,
|
|
||||||
"origin_package": r.origin_package,
|
|
||||||
"elapsed_ms": r.elapsed_ms,
|
|
||||||
"platform_elapsed": r.platform_elapsed,
|
|
||||||
"device_model": r.device_model,
|
|
||||||
"rom": r.rom,
|
|
||||||
"app_env": r.app_env,
|
|
||||||
"started_at": r.started_at,
|
|
||||||
"claimed_count": r.claimed_count,
|
|
||||||
"trace_url": r.trace_url,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _empty_result() -> dict:
|
|
||||||
return {
|
|
||||||
"summary": {
|
|
||||||
"started_count": 0, "completed_count": 0, "avg_elapsed_ms": None,
|
|
||||||
"p5_ms": None, "p50_ms": None, "p95_ms": None, "p99_ms": None,
|
|
||||||
},
|
|
||||||
"daily": [],
|
|
||||||
"hourly": [],
|
|
||||||
"total": 0,
|
|
||||||
"items": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def coupon_data_report(
|
|
||||||
db: Session,
|
|
||||||
*,
|
|
||||||
date_from: str,
|
|
||||||
date_to: str,
|
|
||||||
user: str | None = None,
|
|
||||||
app_env: str | None = None,
|
|
||||||
granularity: str = "day",
|
|
||||||
limit: int = 500,
|
|
||||||
offset: int = 0,
|
|
||||||
sort: str = "time",
|
|
||||||
) -> dict:
|
|
||||||
"""日期区间(北京自然日 started_date,闭区间)领券数据:汇总卡 + 趋势 + 逐条明细。
|
|
||||||
|
|
||||||
- user:手机号/昵称模糊搜(匹配不到任何用户 → 空结果)。
|
|
||||||
- app_env:prod/dev 精确;None=全部。
|
|
||||||
- sort:time=发起时刻倒序(默认) / elapsed=全程耗时倒序(None 末尾)。
|
|
||||||
"""
|
|
||||||
by_hour = granularity == "hour"
|
|
||||||
d_from = _date.fromisoformat(date_from)
|
|
||||||
d_to = _date.fromisoformat(date_to)
|
|
||||||
|
|
||||||
# user 模糊 → 先定位匹配用户 id;匹配不到直接空结果(不全表扫)。
|
|
||||||
user_ids: set[int] | None = None
|
|
||||||
if user:
|
|
||||||
like = f"%{user}%"
|
|
||||||
user_ids = set(db.execute(
|
|
||||||
select(User.id).where(or_(User.phone.like(like), User.nickname.like(like)))
|
|
||||||
).scalars().all())
|
|
||||||
if not user_ids:
|
|
||||||
return _empty_result()
|
|
||||||
|
|
||||||
stmt = select(CouponSession).where(
|
|
||||||
CouponSession.started_date >= d_from,
|
|
||||||
CouponSession.started_date <= d_to,
|
|
||||||
)
|
|
||||||
if app_env is not None:
|
|
||||||
stmt = stmt.where(CouponSession.app_env == app_env)
|
|
||||||
if user_ids is not None:
|
|
||||||
stmt = stmt.where(CouponSession.user_id.in_(user_ids))
|
|
||||||
rows = list(db.execute(stmt).scalars())
|
|
||||||
|
|
||||||
# ── 汇总卡 ──
|
|
||||||
completed_elapsed = sorted(
|
|
||||||
r.elapsed_ms for r in rows if r.status == "completed" and r.elapsed_ms is not None
|
|
||||||
)
|
|
||||||
summary = {
|
|
||||||
"started_count": len(rows),
|
|
||||||
"completed_count": sum(1 for r in rows if r.status == "completed"),
|
|
||||||
"avg_elapsed_ms": _avg(completed_elapsed),
|
|
||||||
"p5_ms": _percentile(completed_elapsed, 5),
|
|
||||||
"p50_ms": _percentile(completed_elapsed, 50),
|
|
||||||
"p95_ms": _percentile(completed_elapsed, 95),
|
|
||||||
"p99_ms": _percentile(completed_elapsed, 99),
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── 按天趋势(柱=发起/完成数,线=平均耗时)──
|
|
||||||
daily_map: dict[str, dict] = {}
|
|
||||||
for r in rows:
|
|
||||||
d = r.started_date.isoformat()
|
|
||||||
b = daily_map.get(d)
|
|
||||||
if b is None:
|
|
||||||
b = {"date": d, "started_count": 0, "completed_count": 0, "_elapsed": []}
|
|
||||||
daily_map[d] = b
|
|
||||||
b["started_count"] += 1
|
|
||||||
if r.status == "completed":
|
|
||||||
b["completed_count"] += 1
|
|
||||||
if r.elapsed_ms is not None:
|
|
||||||
b["_elapsed"].append(r.elapsed_ms)
|
|
||||||
daily = [
|
|
||||||
{
|
|
||||||
"date": b["date"],
|
|
||||||
"started_count": b["started_count"],
|
|
||||||
"completed_count": b["completed_count"],
|
|
||||||
"avg_elapsed_ms": _avg(b["_elapsed"]),
|
|
||||||
}
|
|
||||||
for b in sorted(daily_map.values(), key=lambda x: x["date"])
|
|
||||||
]
|
|
||||||
|
|
||||||
# ── 按小时趋势(单日 hour 粒度)──
|
|
||||||
hourly: list[dict] = []
|
|
||||||
if by_hour:
|
|
||||||
hour_map: dict[int, dict] = {}
|
|
||||||
for r in rows:
|
|
||||||
h = _cn_hour(r.started_at)
|
|
||||||
b = hour_map.get(h)
|
|
||||||
if b is None:
|
|
||||||
b = {"hour": h, "started_count": 0, "completed_count": 0, "_elapsed": []}
|
|
||||||
hour_map[h] = b
|
|
||||||
b["started_count"] += 1
|
|
||||||
if r.status == "completed":
|
|
||||||
b["completed_count"] += 1
|
|
||||||
if r.elapsed_ms is not None:
|
|
||||||
b["_elapsed"].append(r.elapsed_ms)
|
|
||||||
hourly = [
|
|
||||||
{
|
|
||||||
"hour": b["hour"],
|
|
||||||
"started_count": b["started_count"],
|
|
||||||
"completed_count": b["completed_count"],
|
|
||||||
"avg_elapsed_ms": _avg(b["_elapsed"]),
|
|
||||||
}
|
|
||||||
for b in sorted(hour_map.values(), key=lambda x: x["hour"])
|
|
||||||
]
|
|
||||||
|
|
||||||
# ── 明细:排序 + 分页 + 补用户手机号/昵称(批量,防 N+1)──
|
|
||||||
if sort == "elapsed":
|
|
||||||
rows.sort(key=lambda r: (r.elapsed_ms is None, -(r.elapsed_ms or 0)))
|
|
||||||
else: # time:发起时刻倒序
|
|
||||||
rows.sort(key=lambda r: r.started_at, reverse=True)
|
|
||||||
page = rows[offset:offset + limit]
|
|
||||||
|
|
||||||
uids = {r.user_id for r in page if r.user_id is not None}
|
|
||||||
user_map: dict[int, tuple[str | None, str | None]] = {}
|
|
||||||
if uids:
|
|
||||||
user_map = {
|
|
||||||
uid: (phone, nickname)
|
|
||||||
for uid, phone, nickname in db.execute(
|
|
||||||
select(User.id, User.phone, User.nickname).where(User.id.in_(uids))
|
|
||||||
).all()
|
|
||||||
}
|
|
||||||
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))
|
|
||||||
|
|
||||||
return {
|
|
||||||
"summary": summary,
|
|
||||||
"daily": daily,
|
|
||||||
"hourly": hourly,
|
|
||||||
"total": len(rows),
|
|
||||||
"items": items,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def coupon_user_records(db: Session, *, user_id: int, limit: int = 100) -> dict:
|
|
||||||
"""某用户全部领券记录(点手机号抽屉用):按发起时刻倒序、不限日期,total=该用户领券总次数。"""
|
|
||||||
rows = list(db.execute(
|
|
||||||
select(CouponSession)
|
|
||||||
.where(CouponSession.user_id == user_id)
|
|
||||||
.order_by(CouponSession.started_at.desc())
|
|
||||||
.limit(limit)
|
|
||||||
).scalars())
|
|
||||||
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)}
|
|
||||||
@@ -7,14 +7,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
from typing import Any
|
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from sqlalchemy import desc, func, select
|
from sqlalchemy import desc, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.admin.repositories.queries import _as_utc, offset_paginate
|
from app.admin.repositories.queries import _as_utc, offset_paginate
|
||||||
from app.integrations import jd_union, meituan
|
from app.integrations import meituan
|
||||||
from app.repositories import cps_link as cps_link_repo
|
from app.repositories import cps_link as cps_link_repo
|
||||||
from app.models.cps_activity import CpsActivity
|
from app.models.cps_activity import CpsActivity
|
||||||
from app.models.cps_group import CpsGroup
|
from app.models.cps_group import CpsGroup
|
||||||
@@ -25,11 +24,6 @@ from app.models.cps_wx_user import CpsWxUser
|
|||||||
# 美团订单状态:取消(4)/风控(5)不计佣金;结算(6)为佣金真正到账
|
# 美团订单状态:取消(4)/风控(5)不计佣金;结算(6)为佣金真正到账
|
||||||
_INVALID_STATUS = {"4", "5"}
|
_INVALID_STATUS = {"4", "5"}
|
||||||
_SETTLED_STATUS = "6"
|
_SETTLED_STATUS = "6"
|
||||||
_JD_INVALID_CODES = {
|
|
||||||
"2", "3", "4", "5", "6", "7", "8", "9", "11", "13", "14", "19", "20", "21",
|
|
||||||
"22", "23", "25", "26", "27", "28", "29", "30", "31", "34", "35", "36",
|
|
||||||
}
|
|
||||||
_JD_UNPAID_CODES = {"15"}
|
|
||||||
|
|
||||||
# CPS 点击时序按北京时区分桶(运营看的是北京时间)
|
# CPS 点击时序按北京时区分桶(运营看的是北京时间)
|
||||||
_BJ_TZ = timezone(timedelta(hours=8))
|
_BJ_TZ = timezone(timedelta(hours=8))
|
||||||
@@ -53,36 +47,6 @@ def _ts_to_dt(ts: object) -> datetime | None:
|
|||||||
"""秒级时间戳 → tz-aware UTC datetime(绝对时刻,前端按北京展示)。"""
|
"""秒级时间戳 → tz-aware UTC datetime(绝对时刻,前端按北京展示)。"""
|
||||||
if not ts:
|
if not ts:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _jd_dt_to_utc(value: object) -> datetime | None:
|
|
||||||
"""京东时间字符串(北京时间) → UTC aware datetime。"""
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
s = str(value).strip()
|
|
||||||
if not s:
|
|
||||||
return None
|
|
||||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
|
|
||||||
try:
|
|
||||||
dt = datetime.strptime(s, fmt)
|
|
||||||
return dt.replace(tzinfo=_BJ_TZ).astimezone(timezone.utc)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _text(value: object) -> str | None:
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
s = str(value).strip()
|
|
||||||
return s or None
|
|
||||||
|
|
||||||
|
|
||||||
def _pick(row: dict[str, Any], *keys: str) -> Any:
|
|
||||||
for key in keys:
|
|
||||||
if key in row and row[key] is not None:
|
|
||||||
return row[key]
|
|
||||||
return None
|
|
||||||
try:
|
try:
|
||||||
return datetime.fromtimestamp(int(ts), tz=timezone.utc)
|
return datetime.fromtimestamp(int(ts), tz=timezone.utc)
|
||||||
except (ValueError, OSError, TypeError):
|
except (ValueError, OSError, TypeError):
|
||||||
@@ -285,80 +249,6 @@ def _map_order_fields(r: dict) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _jd_order_key(r: dict[str, Any]) -> str | None:
|
|
||||||
row_id = _text(_pick(r, "id", "rowId", "orderRowId"))
|
|
||||||
if row_id:
|
|
||||||
return f"jd:{row_id}"
|
|
||||||
order_id = _text(_pick(r, "orderId", "parentOrderId"))
|
|
||||||
sku_id = _text(_pick(r, "skuId"))
|
|
||||||
if order_id and sku_id:
|
|
||||||
return f"jd:{order_id}:{sku_id}"
|
|
||||||
if order_id:
|
|
||||||
return f"jd:{order_id}"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _map_jd_order_fields(r: dict[str, Any]) -> dict:
|
|
||||||
"""京东 order.row.query 单条订单行 → CpsOrder 字段。"""
|
|
||||||
sku_name = _text(_pick(r, "skuName", "goodsName", "productName"))
|
|
||||||
if sku_name and len(sku_name) > 500:
|
|
||||||
sku_name = sku_name[:500]
|
|
||||||
valid_code = _text(_pick(r, "validCode", "valid_code"))
|
|
||||||
actual_fee = _yuan_to_cents(_pick(r, "actualFee", "actual_fee"))
|
|
||||||
estimate_fee = _yuan_to_cents(_pick(r, "estimateFee", "estimate_fee"))
|
|
||||||
commission = actual_fee if actual_fee not in (None, 0) else estimate_fee
|
|
||||||
order_time = _jd_dt_to_utc(_pick(r, "orderTime", "order_time"))
|
|
||||||
return {
|
|
||||||
"platform": "jd",
|
|
||||||
"external_order_id": _text(_pick(r, "orderId", "parentOrderId")),
|
|
||||||
"external_row_id": _text(_pick(r, "id", "rowId", "orderRowId")),
|
|
||||||
"sid": _text(_pick(r, "subUnionId", "sub_union_id")),
|
|
||||||
"act_id": None,
|
|
||||||
"biz_line": None,
|
|
||||||
"trade_type": None,
|
|
||||||
"pay_price_cents": _yuan_to_cents(
|
|
||||||
_pick(r, "actualCosPrice", "estimateCosPrice", "price")
|
|
||||||
),
|
|
||||||
"commission_cents": commission,
|
|
||||||
"commission_rate": _text(_pick(r, "commissionRate", "commission_rate")),
|
|
||||||
"refund_price_cents": None,
|
|
||||||
"refund_profit_cents": None,
|
|
||||||
"estimated_commission_cents": estimate_fee,
|
|
||||||
"actual_commission_cents": actual_fee,
|
|
||||||
"mt_status": None,
|
|
||||||
"jd_valid_code": valid_code,
|
|
||||||
"invalid_reason": None if _is_jd_valid_code(valid_code) else f"validCode={valid_code}",
|
|
||||||
"product_name": sku_name,
|
|
||||||
"settle_month": _text(_pick(r, "payMonth", "settleMonth", "pay_month")),
|
|
||||||
"site_id": _text(_pick(r, "siteId", "site_id")),
|
|
||||||
"position_id": _text(_pick(r, "positionId", "position_id")),
|
|
||||||
"pid": _text(_pick(r, "pid")),
|
|
||||||
"sub_union_id": _text(_pick(r, "subUnionId", "sub_union_id")),
|
|
||||||
"pay_time": order_time,
|
|
||||||
"mt_update_time": _jd_dt_to_utc(_pick(r, "modifyTime", "updateTime", "modify_time"))
|
|
||||||
or order_time,
|
|
||||||
"raw": r,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _is_jd_valid_code(valid_code: str | None) -> bool:
|
|
||||||
code = str(valid_code).strip() if valid_code is not None else ""
|
|
||||||
return bool(code and code not in _JD_INVALID_CODES and code not in _JD_UNPAID_CODES)
|
|
||||||
|
|
||||||
|
|
||||||
def is_jd_order_valid(order: CpsOrder) -> bool:
|
|
||||||
return _is_jd_valid_code(order.jd_valid_code)
|
|
||||||
|
|
||||||
|
|
||||||
def effective_commission_cents(order: CpsOrder) -> int:
|
|
||||||
if order.platform == "jd":
|
|
||||||
if order.actual_commission_cents not in (None, 0):
|
|
||||||
return order.actual_commission_cents or 0
|
|
||||||
if order.estimated_commission_cents is not None:
|
|
||||||
return order.estimated_commission_cents or 0
|
|
||||||
return order.commission_cents or 0
|
|
||||||
|
|
||||||
|
|
||||||
def reconcile_orders(
|
def reconcile_orders(
|
||||||
db: Session, *, start_time: int, end_time: int,
|
db: Session, *, start_time: int, end_time: int,
|
||||||
query_time_type: int = 1, sid: str | None = None, max_pages: int = 200,
|
query_time_type: int = 1, sid: str | None = None, max_pages: int = 200,
|
||||||
@@ -384,11 +274,6 @@ def reconcile_orders(
|
|||||||
continue
|
continue
|
||||||
fetched += 1
|
fetched += 1
|
||||||
fields = _map_order_fields(r)
|
fields = _map_order_fields(r)
|
||||||
fields.setdefault("platform", "meituan")
|
|
||||||
fields.setdefault("external_order_id", order_id)
|
|
||||||
fields.setdefault("external_row_id", None)
|
|
||||||
fields.setdefault("estimated_commission_cents", fields.get("commission_cents"))
|
|
||||||
fields.setdefault("actual_commission_cents", None)
|
|
||||||
existing = db.execute(
|
existing = db.execute(
|
||||||
select(CpsOrder).where(CpsOrder.order_id == order_id)
|
select(CpsOrder).where(CpsOrder.order_id == order_id)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
@@ -406,56 +291,6 @@ def reconcile_orders(
|
|||||||
return {"fetched": fetched, "inserted": inserted, "updated": updated, "pages": pages}
|
return {"fetched": fetched, "inserted": inserted, "updated": updated, "pages": pages}
|
||||||
|
|
||||||
|
|
||||||
def reconcile_jd_orders(
|
|
||||||
db: Session, *, start_time: datetime, end_time: datetime,
|
|
||||||
query_time_type: int = 3, max_pages: int = 100,
|
|
||||||
) -> dict:
|
|
||||||
"""调京东 order.row.query 拉单 → 按订单行 upsert。
|
|
||||||
|
|
||||||
京东单次查询窗口最多 1 小时,这里按北京自然时间切窗并逐页拉取。
|
|
||||||
"""
|
|
||||||
fetched = inserted = updated = pages = 0
|
|
||||||
cur = start_time
|
|
||||||
while cur < end_time:
|
|
||||||
win_end = min(cur + timedelta(hours=1), end_time)
|
|
||||||
page = 1
|
|
||||||
while page <= max_pages:
|
|
||||||
resp = jd_union.query_order_rows(
|
|
||||||
start_time=cur,
|
|
||||||
end_time=win_end,
|
|
||||||
query_time_type=query_time_type,
|
|
||||||
page_index=page,
|
|
||||||
page_size=200,
|
|
||||||
)
|
|
||||||
rows = resp.get("rows") or []
|
|
||||||
has_more = bool(resp.get("has_more"))
|
|
||||||
if not rows:
|
|
||||||
break
|
|
||||||
pages += 1
|
|
||||||
for r in rows:
|
|
||||||
order_id = _jd_order_key(r)
|
|
||||||
if not order_id:
|
|
||||||
continue
|
|
||||||
fetched += 1
|
|
||||||
fields = _map_jd_order_fields(r)
|
|
||||||
existing = db.execute(
|
|
||||||
select(CpsOrder).where(CpsOrder.order_id == order_id)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
if existing is None:
|
|
||||||
db.add(CpsOrder(order_id=order_id, **fields))
|
|
||||||
inserted += 1
|
|
||||||
else:
|
|
||||||
for k, v in fields.items():
|
|
||||||
setattr(existing, k, v)
|
|
||||||
updated += 1
|
|
||||||
if not has_more or len(rows) < 200:
|
|
||||||
break
|
|
||||||
page += 1
|
|
||||||
cur = win_end
|
|
||||||
db.commit()
|
|
||||||
return {"fetched": fetched, "inserted": inserted, "updated": updated, "pages": pages}
|
|
||||||
|
|
||||||
|
|
||||||
def list_orders(
|
def list_orders(
|
||||||
db: Session, *, sid: str | None = None, mt_status: str | None = None,
|
db: Session, *, sid: str | None = None, mt_status: str | None = None,
|
||||||
limit: int = 20, cursor: int | None = None,
|
limit: int = 20, cursor: int | None = None,
|
||||||
|
|||||||
@@ -65,19 +65,16 @@ def review_feedback(
|
|||||||
reward_coins: int | None = None,
|
reward_coins: int | None = None,
|
||||||
reject_reason: str | None = None,
|
reject_reason: str | None = None,
|
||||||
review_note: str | None = None,
|
review_note: str | None = None,
|
||||||
admin_reply: str | None = None,
|
|
||||||
commit: bool = True,
|
commit: bool = True,
|
||||||
) -> Feedback:
|
) -> Feedback:
|
||||||
"""审核反馈:置 adopted/rejected + 记录奖励/原因/回复留言/审核人/审核时间。
|
"""审核反馈:置 adopted/rejected + 记录奖励/原因/审核人/审核时间。
|
||||||
|
|
||||||
发金币(wallet.grant_coins)由 router 在同一事务里调,确保状态、金币流水、审计一起提交。
|
发金币(wallet.grant_coins)由 router 在同一事务里调,确保状态、金币流水、审计一起提交。
|
||||||
admin_reply=给用户的回复留言(用户端可见),与 review_note(内部备注)区分。
|
|
||||||
"""
|
"""
|
||||||
feedback.status = status
|
feedback.status = status
|
||||||
feedback.reward_coins = reward_coins
|
feedback.reward_coins = reward_coins
|
||||||
feedback.reject_reason = reject_reason
|
feedback.reject_reason = reject_reason
|
||||||
feedback.review_note = review_note
|
feedback.review_note = review_note
|
||||||
feedback.admin_reply = admin_reply
|
|
||||||
feedback.reviewed_by_admin_id = reviewed_by_admin_id
|
feedback.reviewed_by_admin_id = reviewed_by_admin_id
|
||||||
feedback.reviewed_at = datetime.now(CN_TZ).replace(tzinfo=None)
|
feedback.reviewed_at = datetime.now(CN_TZ).replace(tzinfo=None)
|
||||||
if commit:
|
if commit:
|
||||||
|
|||||||
@@ -125,9 +125,8 @@ def list_users(
|
|||||||
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
|
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
|
||||||
|
|
||||||
|
|
||||||
def _attach_user_info(db: Session, records: list[ComparisonRecord | Feedback | PriceReport]) -> None:
|
def _attach_user_info(db: Session, records: list[ComparisonRecord]) -> None:
|
||||||
"""给每条记录瞬态挂 phone/nickname(非 DB 列,供 admin schema from_attributes 读)。
|
"""给每条比价记录瞬态挂 phone/nickname(非 DB 列,供 admin schema from_attributes 读)。"""
|
||||||
按 user_id 鸭子类型,比价记录/反馈/上报通用。"""
|
|
||||||
uids = {r.user_id for r in records}
|
uids = {r.user_id for r in records}
|
||||||
if not uids:
|
if not uids:
|
||||||
return
|
return
|
||||||
@@ -390,7 +389,6 @@ def list_all_withdraw_orders(
|
|||||||
*,
|
*,
|
||||||
user_id: int | None = None,
|
user_id: int | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
source: str | None = None,
|
|
||||||
keyword: str | None = None,
|
keyword: str | None = None,
|
||||||
date_from: datetime | None = None,
|
date_from: datetime | None = None,
|
||||||
date_to: datetime | None = None,
|
date_to: datetime | None = None,
|
||||||
@@ -410,9 +408,6 @@ def list_all_withdraw_orders(
|
|||||||
stmt = stmt.where(WithdrawOrder.user_id == user_id)
|
stmt = stmt.where(WithdrawOrder.user_id == user_id)
|
||||||
if status:
|
if status:
|
||||||
stmt = stmt.where(WithdrawOrder.status == status)
|
stmt = stmt.where(WithdrawOrder.status == status)
|
||||||
# 提现类型:coin_cash(福利页提现)/ invite_cash(邀请提现);None=全部
|
|
||||||
if source:
|
|
||||||
stmt = stmt.where(WithdrawOrder.source == source)
|
|
||||||
|
|
||||||
kw = (keyword or "").strip()
|
kw = (keyword or "").strip()
|
||||||
if kw:
|
if kw:
|
||||||
@@ -538,7 +533,6 @@ def list_feedbacks(
|
|||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
source: str | None = None,
|
|
||||||
user_id: int | None = None,
|
user_id: int | None = None,
|
||||||
content: str | None = None,
|
content: str | None = None,
|
||||||
created_from: datetime | None = None,
|
created_from: datetime | None = None,
|
||||||
@@ -548,15 +542,13 @@ def list_feedbacks(
|
|||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
cursor: int | None = None,
|
cursor: int | None = None,
|
||||||
) -> tuple[list[Feedback], int | None, int]:
|
) -> tuple[list[Feedback], int | None, int]:
|
||||||
"""反馈工单列表。支持 状态 / 反馈类型(source) / 用户ID / 内容模糊 / 提交时间范围 筛选,
|
"""反馈工单列表。支持 状态 / 用户ID / 内容模糊 / 提交时间范围 筛选,按 id·提交时间排序。
|
||||||
按 id·提交时间排序。**offset 分页**(cursor=offset):任意列排序下游标语义统一(同 [list_users]),
|
**offset 分页**(cursor=offset):任意列排序下游标语义统一(同 [list_users]),代价是翻页期间
|
||||||
代价是翻页期间数据变动可能错位一条——admin 低频场景可接受。返回 (items, next_cursor, total),
|
数据变动可能错位一条——admin 低频场景可接受。返回 (items, next_cursor, total),total 供页码分页。
|
||||||
total 供页码分页。created_at 为 timestamptz,日期入参统一转 tz-aware UTC 比较。"""
|
created_at 为 timestamptz,日期入参统一转 tz-aware UTC 比较。"""
|
||||||
stmt = select(Feedback)
|
stmt = select(Feedback)
|
||||||
if status:
|
if status:
|
||||||
stmt = stmt.where(Feedback.status == status)
|
stmt = stmt.where(Feedback.status == status)
|
||||||
if source:
|
|
||||||
stmt = stmt.where(Feedback.source == source)
|
|
||||||
if user_id is not None:
|
if user_id is not None:
|
||||||
stmt = stmt.where(Feedback.user_id == user_id)
|
stmt = stmt.where(Feedback.user_id == user_id)
|
||||||
if content and content.strip():
|
if content and content.strip():
|
||||||
@@ -570,25 +562,7 @@ def list_feedbacks(
|
|||||||
sort_col = sort_cols.get(sort_by, Feedback.id)
|
sort_col = sort_cols.get(sort_by, Feedback.id)
|
||||||
order_fn = asc if sort_order == "asc" else desc
|
order_fn = asc if sort_order == "asc" else desc
|
||||||
id_order = asc(Feedback.id) if sort_order == "asc" else desc(Feedback.id)
|
id_order = asc(Feedback.id) if sort_order == "asc" else desc(Feedback.id)
|
||||||
items, next_cursor, total = offset_paginate(
|
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
|
||||||
db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor
|
|
||||||
)
|
|
||||||
_attach_user_info(db, items) # 列表展示完整手机号(点手机号查该用户全部反馈)
|
|
||||||
return items, next_cursor, total
|
|
||||||
|
|
||||||
|
|
||||||
def feedback_summary(db: Session) -> dict:
|
|
||||||
"""反馈审核台各状态计数(待审核/已采纳/未采纳/合计)。待审核含历史 new 态(与前端 isPending 一致)。"""
|
|
||||||
rows = db.execute(
|
|
||||||
select(Feedback.status, func.count(Feedback.id)).group_by(Feedback.status)
|
|
||||||
).all()
|
|
||||||
by_status = {status: int(count) for status, count in rows}
|
|
||||||
return {
|
|
||||||
"pending": by_status.get("pending", 0) + by_status.get("new", 0),
|
|
||||||
"adopted": by_status.get("adopted", 0),
|
|
||||||
"rejected": by_status.get("rejected", 0),
|
|
||||||
"total": sum(by_status.values()),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def list_analytics_events(
|
def list_analytics_events(
|
||||||
@@ -795,7 +769,6 @@ def get_user_overview(db: Session, user_id: int) -> dict | None:
|
|||||||
"user": user,
|
"user": user,
|
||||||
"coin_balance": acc.coin_balance if acc else 0,
|
"coin_balance": acc.coin_balance if acc else 0,
|
||||||
"cash_balance_cents": acc.cash_balance_cents if acc else 0,
|
"cash_balance_cents": acc.cash_balance_cents if acc else 0,
|
||||||
"invite_cash_balance_cents": acc.invite_cash_balance_cents if acc else 0,
|
|
||||||
"total_coin_earned": acc.total_coin_earned if acc else 0,
|
"total_coin_earned": acc.total_coin_earned if acc else 0,
|
||||||
"comparison_total": _count(ComparisonRecord, ComparisonRecord.user_id == user_id),
|
"comparison_total": _count(ComparisonRecord, ComparisonRecord.user_id == user_id),
|
||||||
"comparison_success": _count(
|
"comparison_success": _count(
|
||||||
@@ -813,12 +786,6 @@ def get_user_overview(db: Session, user_id: int) -> dict | None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _as_utc_naive(value: datetime) -> datetime:
|
|
||||||
"""窗口入参 → UTC naive(= _as_utc 去时区),与库里按 naive UTC 存取的 created_at 同口径比较。
|
|
||||||
历史遗留:_window_conds 一直引用本函数却未定义(自定义区间会 NameError),此处补上。"""
|
|
||||||
return _as_utc(value).replace(tzinfo=None)
|
|
||||||
|
|
||||||
|
|
||||||
def _window_conds(col, date_from: datetime | None, date_to: datetime | None) -> list:
|
def _window_conds(col, date_from: datetime | None, date_to: datetime | None) -> list:
|
||||||
"""把 [date_from, date_to] 转成对 col(created_at)的过滤条件;都为 None = 全量(注册至今)。"""
|
"""把 [date_from, date_to] 转成对 col(created_at)的过滤条件;都为 None = 全量(注册至今)。"""
|
||||||
conds = []
|
conds = []
|
||||||
@@ -908,13 +875,6 @@ def user_reward_stats(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _cn_wall_to_utc(dt: datetime) -> datetime:
|
|
||||||
"""coin_transaction 存的是北京 wall-clock(naive,见 wallet.grant_coins「存北京 wall-clock」),转成 UTC naive,
|
|
||||||
与广告表(func.now() UTC)统一 —— 让本函数按同一绝对时刻排序、且前端 apiTime(把无时区时间当 UTC 再 +8 展示)
|
|
||||||
口径一致;否则签到会比实际多显示 8 小时(北京时间又被 +8)。"""
|
|
||||||
return dt.replace(tzinfo=rewards.CN_TZ).astimezone(timezone.utc).replace(tzinfo=None)
|
|
||||||
|
|
||||||
|
|
||||||
def user_coin_records(
|
def user_coin_records(
|
||||||
db: Session,
|
db: Session,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
@@ -935,9 +895,6 @@ def user_coin_records(
|
|||||||
offset = max(cursor or 0, 0)
|
offset = max(cursor or 0, 0)
|
||||||
fetch = offset + limit + 1
|
fetch = offset + limit + 1
|
||||||
rows: list[dict] = []
|
rows: list[dict] = []
|
||||||
# coin_transaction 存北京 wall-clock(其余表存 UTC);签到窗口边界 +8h 对齐北京,过滤/计数才不偏移 8 小时
|
|
||||||
signin_from = date_from + timedelta(hours=8) if date_from is not None else None
|
|
||||||
signin_to = date_to + timedelta(hours=8) if date_to is not None else None
|
|
||||||
|
|
||||||
for rec in db.execute(
|
for rec in db.execute(
|
||||||
select(AdRewardRecord)
|
select(AdRewardRecord)
|
||||||
@@ -981,7 +938,7 @@ def user_coin_records(
|
|||||||
.where(
|
.where(
|
||||||
CoinTransaction.user_id == user_id,
|
CoinTransaction.user_id == user_id,
|
||||||
CoinTransaction.biz_type == "signin",
|
CoinTransaction.biz_type == "signin",
|
||||||
*_window_conds(CoinTransaction.created_at, signin_from, signin_to),
|
*_window_conds(CoinTransaction.created_at, date_from, date_to),
|
||||||
)
|
)
|
||||||
.order_by(CoinTransaction.created_at.desc())
|
.order_by(CoinTransaction.created_at.desc())
|
||||||
.limit(fetch)
|
.limit(fetch)
|
||||||
@@ -989,8 +946,7 @@ def user_coin_records(
|
|||||||
rows.append({
|
rows.append({
|
||||||
"source": "signin",
|
"source": "signin",
|
||||||
"source_label": "签到",
|
"source_label": "签到",
|
||||||
# 北京 wall-clock → UTC,与广告记录统一(前端 apiTime 会 +8 回北京展示,不然签到会多 8 小时)
|
"created_at": rec.created_at,
|
||||||
"created_at": _cn_wall_to_utc(rec.created_at),
|
|
||||||
"ecpm": None,
|
"ecpm": None,
|
||||||
"coin": rec.amount,
|
"coin": rec.amount,
|
||||||
})
|
})
|
||||||
@@ -1016,72 +972,27 @@ def user_coin_records(
|
|||||||
+ _count(
|
+ _count(
|
||||||
CoinTransaction, CoinTransaction.user_id == user_id,
|
CoinTransaction, CoinTransaction.user_id == user_id,
|
||||||
CoinTransaction.biz_type == "signin",
|
CoinTransaction.biz_type == "signin",
|
||||||
*_window_conds(CoinTransaction.created_at, signin_from, signin_to),
|
*_window_conds(CoinTransaction.created_at, date_from, date_to),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return rows[offset:offset + limit], (offset + limit if has_more else None), total
|
return rows[offset:offset + limit], (offset + limit if has_more else None), total
|
||||||
|
|
||||||
|
|
||||||
def _attach_price_report_comparison(db: Session, records: list[PriceReport]) -> None:
|
|
||||||
"""给每条上报瞬态挂关联比价记录的 trace + 设备/版本快照:
|
|
||||||
trace_id/trace_url(点 Trace 看完整比价过程)、device_model/rom_name/android_version(机型OS版本列)、
|
|
||||||
app_version(提交版本号列,= 提交时我们 app 的 versionName)。
|
|
||||||
comparison_record_id 为空 / 关联记录查不到 → 全 None。"""
|
|
||||||
cids = {r.comparison_record_id for r in records if r.comparison_record_id is not None}
|
|
||||||
rows = (
|
|
||||||
db.execute(
|
|
||||||
select(
|
|
||||||
ComparisonRecord.id,
|
|
||||||
ComparisonRecord.trace_id,
|
|
||||||
ComparisonRecord.trace_url,
|
|
||||||
ComparisonRecord.device_model,
|
|
||||||
ComparisonRecord.rom_name,
|
|
||||||
ComparisonRecord.android_version,
|
|
||||||
ComparisonRecord.app_version,
|
|
||||||
).where(ComparisonRecord.id.in_(cids))
|
|
||||||
).all()
|
|
||||||
if cids
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
cmap = {row.id: row for row in rows}
|
|
||||||
for r in records:
|
|
||||||
row = cmap.get(r.comparison_record_id)
|
|
||||||
r.trace_id = row.trace_id if row else None
|
|
||||||
r.trace_url = row.trace_url if row else None
|
|
||||||
r.device_model = row.device_model if row else None
|
|
||||||
r.rom_name = row.rom_name if row else None
|
|
||||||
r.android_version = row.android_version if row else None
|
|
||||||
r.app_version = row.app_version if row else None
|
|
||||||
|
|
||||||
|
|
||||||
def list_price_reports(
|
def list_price_reports(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
user_id: int | None = None,
|
user_id: int | None = None,
|
||||||
sort_by: str = "id",
|
|
||||||
sort_order: str = "desc",
|
|
||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
cursor: int | None = None,
|
cursor: int | None = None,
|
||||||
) -> tuple[list[PriceReport], int | None, int]:
|
) -> tuple[list[PriceReport], int | None, int]:
|
||||||
"""上报更低价列表(admin 全量,可按状态/用户筛)。offset 分页 + total,按 id·提交时间排序。
|
"""上报更低价列表(admin 全量,可按状态/用户筛)。offset 分页 + total,id 倒序。"""
|
||||||
join User 取 phone 瞬态挂(列表展示完整手机号);按 comparison_record_id 挂 trace_id/trace_url。"""
|
|
||||||
stmt = select(PriceReport)
|
stmt = select(PriceReport)
|
||||||
if status:
|
if status:
|
||||||
stmt = stmt.where(PriceReport.status == status)
|
stmt = stmt.where(PriceReport.status == status)
|
||||||
if user_id is not None:
|
if user_id is not None:
|
||||||
stmt = stmt.where(PriceReport.user_id == user_id)
|
stmt = stmt.where(PriceReport.user_id == user_id)
|
||||||
|
return offset_paginate(db, stmt, (PriceReport.id.desc(),), limit=limit, cursor=cursor)
|
||||||
sort_cols = {"id": PriceReport.id, "created_at": PriceReport.created_at}
|
|
||||||
sort_col = sort_cols.get(sort_by, PriceReport.id)
|
|
||||||
order_fn = asc if sort_order == "asc" else desc
|
|
||||||
id_order = asc(PriceReport.id) if sort_order == "asc" else desc(PriceReport.id)
|
|
||||||
items, next_cursor, total = offset_paginate(
|
|
||||||
db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor
|
|
||||||
)
|
|
||||||
_attach_user_info(db, items)
|
|
||||||
_attach_price_report_comparison(db, items)
|
|
||||||
return items, next_cursor, total
|
|
||||||
|
|
||||||
|
|
||||||
def price_report_summary(db: Session) -> dict:
|
def price_report_summary(db: Session) -> dict:
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||||
from app.models.ad_reward import AdRewardRecord
|
from app.models.ad_reward import AdRewardRecord
|
||||||
from app.models.analytics_event import AnalyticsEvent
|
|
||||||
from app.models.comparison import ComparisonRecord
|
from app.models.comparison import ComparisonRecord
|
||||||
from app.models.coupon_state import CouponPromptEngagement
|
from app.models.coupon_state import CouponPromptEngagement
|
||||||
from app.models.cps_order import CpsOrder
|
from app.models.cps_order import CpsOrder
|
||||||
@@ -36,13 +35,6 @@ REGULAR_TASK_EXCLUDED_BIZ_TYPES = (
|
|||||||
)
|
)
|
||||||
MEITUAN_CPS_INVALID_STATUSES = ("4", "5")
|
MEITUAN_CPS_INVALID_STATUSES = ("4", "5")
|
||||||
MEITUAN_CPS_SETTLED_STATUS = "6"
|
MEITUAN_CPS_SETTLED_STATUS = "6"
|
||||||
COMPARE_START_EVENT = "real_compare_start"
|
|
||||||
COUPON_START_EVENT = "real_coupon_start"
|
|
||||||
JD_CPS_INVALID_CODES = {
|
|
||||||
"2", "3", "4", "5", "6", "7", "8", "9", "11", "13", "14", "19", "20", "21",
|
|
||||||
"22", "23", "25", "26", "27", "28", "29", "30", "31", "34", "35", "36",
|
|
||||||
}
|
|
||||||
JD_CPS_UNPAID_CODES = {"15"}
|
|
||||||
|
|
||||||
|
|
||||||
def _beijing_today_start_utc() -> datetime:
|
def _beijing_today_start_utc() -> datetime:
|
||||||
@@ -53,31 +45,14 @@ def _beijing_today_start_utc() -> datetime:
|
|||||||
|
|
||||||
|
|
||||||
def today_dau(db: Session) -> int:
|
def today_dau(db: Session) -> int:
|
||||||
"""今日活跃用户数(DAU):登录 + 开始比价 + 开始领券,按用户去重。
|
"""今日活跃用户数(DAU):北京时区今天 0 点后登录过(last_login_at)。
|
||||||
|
|
||||||
广告收益报表复用这个函数;历史窗口 DAU 由 dashboard_overview 的 period 口径另算。
|
广告收益报表复用这个函数;历史窗口 DAU 由 dashboard_overview 的 period 口径另算。
|
||||||
"""
|
"""
|
||||||
today_bj = datetime.now(_BEIJING).date()
|
|
||||||
today_start = _beijing_today_start_utc()
|
today_start = _beijing_today_start_utc()
|
||||||
tomorrow_start = today_start + timedelta(days=1)
|
return int(
|
||||||
login_user_ids = _id_set(
|
db.execute(select(func.count(User.id)).where(User.last_login_at >= today_start)).scalar_one()
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def _default_period_end() -> date:
|
def _default_period_end() -> date:
|
||||||
@@ -116,24 +91,6 @@ def _date_range(date_from: date, date_to: date) -> list[date]:
|
|||||||
return [date_from + timedelta(days=i) for i in range(days + 1)]
|
return [date_from + timedelta(days=i) for i in range(days + 1)]
|
||||||
|
|
||||||
|
|
||||||
def _id_set(db: Session, stmt) -> set[int]:
|
|
||||||
return {int(v) for v in db.execute(stmt).scalars().all() if v is not None}
|
|
||||||
|
|
||||||
|
|
||||||
def _event_user_ids(
|
|
||||||
db: Session, event_names: tuple[str, ...], start_utc: datetime, end_utc: datetime
|
|
||||||
) -> set[int]:
|
|
||||||
return _id_set(
|
|
||||||
db,
|
|
||||||
select(AnalyticsEvent.user_id).where(
|
|
||||||
AnalyticsEvent.user_id.is_not(None),
|
|
||||||
AnalyticsEvent.event.in_(event_names),
|
|
||||||
AnalyticsEvent.created_at >= start_utc,
|
|
||||||
AnalyticsEvent.created_at < end_utc,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _commission_rate_percent(raw: str | None) -> Decimal | None:
|
def _commission_rate_percent(raw: str | None) -> Decimal | None:
|
||||||
"""美团 commissionRate 原值: "300"=3%, "10"=0.1%;也兼容 "3%"。"""
|
"""美团 commissionRate 原值: "300"=3%, "10"=0.1%;也兼容 "3%"。"""
|
||||||
if raw is None:
|
if raw is None:
|
||||||
@@ -150,11 +107,6 @@ def _commission_rate_percent(raw: str | None) -> Decimal | None:
|
|||||||
return val / Decimal("100")
|
return val / Decimal("100")
|
||||||
|
|
||||||
|
|
||||||
def _jd_valid_order(order: CpsOrder) -> bool:
|
|
||||||
code = str(order.jd_valid_code).strip() if order.jd_valid_code is not None else ""
|
|
||||||
return bool(code and code not in JD_CPS_INVALID_CODES and code not in JD_CPS_UNPAID_CODES)
|
|
||||||
|
|
||||||
|
|
||||||
def dashboard_overview(
|
def dashboard_overview(
|
||||||
db: Session, *, date_from: date | None = None, date_to: date | None = None
|
db: Session, *, date_from: date | None = None, date_to: date | None = None
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -264,22 +216,17 @@ def dashboard_overview(
|
|||||||
login_user_ids = _user_id_set(
|
login_user_ids = _user_id_set(
|
||||||
select(User.id).where(User.last_login_at >= start_utc, User.last_login_at < end_utc)
|
select(User.id).where(User.last_login_at >= start_utc, User.last_login_at < end_utc)
|
||||||
)
|
)
|
||||||
compare_start_user_ids = _event_user_ids(
|
compare_user_ids = _user_id_set(
|
||||||
db, (COMPARE_START_EVENT,), start_utc, end_utc
|
select(ComparisonRecord.user_id).where(*period_comparison_conds)
|
||||||
)
|
)
|
||||||
coupon_event_user_ids = _event_user_ids(
|
coupon_user_ids = _user_id_set(
|
||||||
db, (COUPON_START_EVENT,), start_utc, end_utc
|
|
||||||
)
|
|
||||||
coupon_claim_user_ids = _user_id_set(
|
|
||||||
select(CouponPromptEngagement.user_id).where(
|
select(CouponPromptEngagement.user_id).where(
|
||||||
CouponPromptEngagement.engage_date >= period_from,
|
CouponPromptEngagement.engage_date >= period_from,
|
||||||
CouponPromptEngagement.engage_date <= period_to,
|
CouponPromptEngagement.engage_date <= period_to,
|
||||||
CouponPromptEngagement.engage_type == "claim_started",
|
CouponPromptEngagement.engage_type == "claim_started",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
period_active_user_ids = (
|
period_active_user_ids = login_user_ids | compare_user_ids | coupon_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_retained_new_user_ids = period_new_user_ids & period_active_user_ids
|
||||||
period_retention_rate = (
|
period_retention_rate = (
|
||||||
round(len(period_retained_new_user_ids) / len(period_new_user_ids), 4)
|
round(len(period_retained_new_user_ids) / len(period_new_user_ids), 4)
|
||||||
@@ -301,13 +248,10 @@ def dashboard_overview(
|
|||||||
User.last_login_at < day_end_utc,
|
User.last_login_at < day_end_utc,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
daily_compare_start_user_ids = _event_user_ids(
|
daily_compare_user_ids = _user_id_set(
|
||||||
db, (COMPARE_START_EVENT,), day_start_utc, day_end_utc
|
select(ComparisonRecord.user_id).where(*daily_comparison_conds)
|
||||||
)
|
)
|
||||||
daily_coupon_event_user_ids = _event_user_ids(
|
daily_coupon_user_ids = _user_id_set(
|
||||||
db, (COUPON_START_EVENT,), day_start_utc, day_end_utc
|
|
||||||
)
|
|
||||||
daily_coupon_claim_user_ids = _user_id_set(
|
|
||||||
select(CouponPromptEngagement.user_id).where(
|
select(CouponPromptEngagement.user_id).where(
|
||||||
CouponPromptEngagement.engage_date == cur_date,
|
CouponPromptEngagement.engage_date == cur_date,
|
||||||
CouponPromptEngagement.engage_type == "claim_started",
|
CouponPromptEngagement.engage_type == "claim_started",
|
||||||
@@ -317,10 +261,7 @@ def dashboard_overview(
|
|||||||
{
|
{
|
||||||
"date": cur_date,
|
"date": cur_date,
|
||||||
"active_users": len(
|
"active_users": len(
|
||||||
daily_login_user_ids
|
daily_login_user_ids | daily_compare_user_ids | daily_coupon_user_ids
|
||||||
| daily_compare_start_user_ids
|
|
||||||
| daily_coupon_event_user_ids
|
|
||||||
| daily_coupon_claim_user_ids
|
|
||||||
),
|
),
|
||||||
"new_users": _count(
|
"new_users": _count(
|
||||||
User,
|
User,
|
||||||
@@ -376,7 +317,7 @@ def dashboard_overview(
|
|||||||
*period_coin_conds,
|
*period_coin_conds,
|
||||||
CoinTransaction.biz_type.notin_(REGULAR_TASK_EXCLUDED_BIZ_TYPES),
|
CoinTransaction.biz_type.notin_(REGULAR_TASK_EXCLUDED_BIZ_TYPES),
|
||||||
)
|
)
|
||||||
period_cps_orders = list(
|
period_meituan_orders = list(
|
||||||
db.execute(
|
db.execute(
|
||||||
select(CpsOrder).where(
|
select(CpsOrder).where(
|
||||||
CpsOrder.pay_time >= start_utc,
|
CpsOrder.pay_time >= start_utc,
|
||||||
@@ -384,17 +325,9 @@ def dashboard_overview(
|
|||||||
)
|
)
|
||||||
).scalars()
|
).scalars()
|
||||||
)
|
)
|
||||||
period_meituan_orders = [
|
|
||||||
o for o in period_cps_orders if (o.platform or "meituan") == "meituan"
|
|
||||||
]
|
|
||||||
period_meituan_valid_orders = [
|
period_meituan_valid_orders = [
|
||||||
o for o in period_meituan_orders if o.mt_status not in MEITUAN_CPS_INVALID_STATUSES
|
o for o in period_meituan_orders if o.mt_status not in MEITUAN_CPS_INVALID_STATUSES
|
||||||
]
|
]
|
||||||
period_jd_orders = [o for o in period_cps_orders if o.platform == "jd"]
|
|
||||||
period_jd_valid_orders = [o for o in period_jd_orders if _jd_valid_order(o)]
|
|
||||||
period_jd_invalid_orders = [
|
|
||||||
o for o in period_jd_orders if o.jd_valid_code and not _jd_valid_order(o)
|
|
||||||
]
|
|
||||||
period_meituan_hit_count = 0
|
period_meituan_hit_count = 0
|
||||||
period_meituan_miss_count = 0
|
period_meituan_miss_count = 0
|
||||||
period_meituan_unknown_rate_count = 0
|
period_meituan_unknown_rate_count = 0
|
||||||
@@ -479,8 +412,8 @@ def dashboard_overview(
|
|||||||
"retained_new_users": len(period_retained_new_user_ids),
|
"retained_new_users": len(period_retained_new_user_ids),
|
||||||
"retention_rate": period_retention_rate,
|
"retention_rate": period_retention_rate,
|
||||||
"retention_note": (
|
"retention_note": (
|
||||||
"口径:登录(last_login_at)+开始比价(real_compare_start)+"
|
"近似口径:登录(last_login_at)+已上报比价记录+领券claim_started;"
|
||||||
"开始领券(real_coupon_start/claim_started),按用户去重"
|
"尚不包含未完成上报的比价开始事件"
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"comparison": {
|
"comparison": {
|
||||||
@@ -517,7 +450,7 @@ def dashboard_overview(
|
|||||||
},
|
},
|
||||||
"cps": {
|
"cps": {
|
||||||
"available": True,
|
"available": True,
|
||||||
"note": "美团/JD CPS 读 cps_order 对账订单;淘宝佣金暂空",
|
"note": "美团 CPS 读 cps_order 对账订单;淘宝/京东佣金暂空",
|
||||||
"meituan_order_count": len(period_meituan_valid_orders),
|
"meituan_order_count": len(period_meituan_valid_orders),
|
||||||
"meituan_commission_cents": sum(
|
"meituan_commission_cents": sum(
|
||||||
o.commission_cents or 0 for o in period_meituan_valid_orders
|
o.commission_cents or 0 for o in period_meituan_valid_orders
|
||||||
@@ -526,17 +459,5 @@ def dashboard_overview(
|
|||||||
"meituan_miss_count": period_meituan_miss_count,
|
"meituan_miss_count": period_meituan_miss_count,
|
||||||
"meituan_unknown_rate_count": period_meituan_unknown_rate_count,
|
"meituan_unknown_rate_count": period_meituan_unknown_rate_count,
|
||||||
"meituan_hit_rate": period_meituan_hit_rate,
|
"meituan_hit_rate": period_meituan_hit_rate,
|
||||||
"jd_order_count": len(period_jd_valid_orders),
|
|
||||||
# 数据大盘京东 CPS 只看实际佣金,不再用预估佣金兜底。
|
|
||||||
"jd_commission_cents": sum(
|
|
||||||
o.actual_commission_cents or 0 for o in period_jd_valid_orders
|
|
||||||
),
|
|
||||||
"jd_actual_commission_cents": sum(
|
|
||||||
o.actual_commission_cents or 0 for o in period_jd_valid_orders
|
|
||||||
),
|
|
||||||
"jd_estimated_commission_cents": sum(
|
|
||||||
o.estimated_commission_cents or 0 for o in period_jd_valid_orders
|
|
||||||
),
|
|
||||||
"jd_invalid_count": len(period_jd_invalid_orders),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,13 +56,6 @@ def get_ad_revenue_report(
|
|||||||
"全局筛选,同时影响明细 / 合计 / 趋势"
|
"全局筛选,同时影响明细 / 合计 / 趋势"
|
||||||
),
|
),
|
||||||
] = None,
|
] = None,
|
||||||
app_env: Annotated[
|
|
||||||
str | None,
|
|
||||||
Query(
|
|
||||||
description="prod(正式应用) / test(测试应用);不传=全部。"
|
|
||||||
"建议正式收益报表选 prod,避免测试应用的假 eCPM 污染收益合计/平均"
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
granularity: Annotated[
|
granularity: Annotated[
|
||||||
str, Query(description="day=按天 / hour=按小时(北京时间);区间>1 天建议用 day")
|
str, Query(description="day=按天 / hour=按小时(北京时间);区间>1 天建议用 day")
|
||||||
] = "day",
|
] = "day",
|
||||||
@@ -82,7 +75,7 @@ def get_ad_revenue_report(
|
|||||||
|
|
||||||
result = ad_revenue.ad_revenue_report(
|
result = ad_revenue.ad_revenue_report(
|
||||||
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(),
|
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(),
|
||||||
user_id=user_id, ad_type=ad_type, feed_scene=feed_scene, app_env=app_env,
|
user_id=user_id, ad_type=ad_type, feed_scene=feed_scene,
|
||||||
granularity=granularity, limit=limit, offset=offset, sort=sort,
|
granularity=granularity, limit=limit, offset=offset, sort=sort,
|
||||||
)
|
)
|
||||||
return AdRevenueReportOut(
|
return AdRevenueReportOut(
|
||||||
@@ -96,9 +89,6 @@ def get_ad_revenue_report(
|
|||||||
truncated=result["truncated"],
|
truncated=result["truncated"],
|
||||||
total_impressions=result["total_impressions"],
|
total_impressions=result["total_impressions"],
|
||||||
total_revenue_yuan=result["total_revenue_yuan"],
|
total_revenue_yuan=result["total_revenue_yuan"],
|
||||||
total_pangle_revenue_yuan=result["total_pangle_revenue_yuan"],
|
|
||||||
total_pangle_api_revenue_yuan=result["total_pangle_api_revenue_yuan"],
|
|
||||||
pangle_revenue_available=result["pangle_revenue_available"],
|
|
||||||
total_expected_coin=result["total_expected_coin"],
|
total_expected_coin=result["total_expected_coin"],
|
||||||
total_actual_coin=result["total_actual_coin"],
|
total_actual_coin=result["total_actual_coin"],
|
||||||
mismatch_count=result["mismatch_count"],
|
mismatch_count=result["mismatch_count"],
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
"""admin「领券数据」看板:发起/完成数 + 领券耗时(均值 + P5/P50/P95/P99)+ 按天趋势 + 逐条明细。
|
|
||||||
|
|
||||||
任意已登录 admin 可看(只读)。聚合逻辑在 app/admin/repositories/coupon_data.py。
|
|
||||||
数据源 coupon_session(客户端 /api/v1/coupon/session 两段上报)。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date as _date
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
||||||
|
|
||||||
from app.admin.deps import AdminDb, get_current_admin
|
|
||||||
from app.admin.repositories import coupon_data
|
|
||||||
from app.admin.schemas.coupon_data import (
|
|
||||||
CouponDataDaily,
|
|
||||||
CouponDataHourly,
|
|
||||||
CouponDataOut,
|
|
||||||
CouponDataRow,
|
|
||||||
CouponDataSummary,
|
|
||||||
CouponUserRecordsOut,
|
|
||||||
)
|
|
||||||
from app.core.rewards import cn_today
|
|
||||||
|
|
||||||
router = APIRouter(
|
|
||||||
prefix="/admin/api/coupon-data",
|
|
||||||
tags=["admin-coupon-data"],
|
|
||||||
dependencies=[Depends(get_current_admin)],
|
|
||||||
)
|
|
||||||
|
|
||||||
# 区间最大跨度(天);超出拒绝,避免一次拉过多天拖垮接口(对齐广告收益报表)。
|
|
||||||
_MAX_RANGE_DAYS = 92
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_day(value: str | None, *, field: str, default: _date) -> _date:
|
|
||||||
if value is None:
|
|
||||||
return default
|
|
||||||
try:
|
|
||||||
return _date.fromisoformat(value)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=422, detail=f"{field} 需为 YYYY-MM-DD") from e
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"",
|
|
||||||
response_model=CouponDataOut,
|
|
||||||
summary="领券数据看板(发起/完成数 + 耗时分位 + 按天趋势 + 逐条明细)",
|
|
||||||
)
|
|
||||||
def get_coupon_data(
|
|
||||||
db: AdminDb,
|
|
||||||
date_from: Annotated[str | None, Query(description="起始日 北京 YYYY-MM-DD,默认今天")] = None,
|
|
||||||
date_to: Annotated[str | None, Query(description="结束日 北京 YYYY-MM-DD,闭区间,默认=date_from")] = None,
|
|
||||||
user: Annotated[str | None, Query(description="用户手机号/昵称模糊搜;不传=全部")] = None,
|
|
||||||
app_env: Annotated[str, Query(description="prod(默认) / dev / all(全部环境)")] = "prod",
|
|
||||||
granularity: Annotated[
|
|
||||||
str, Query(description="day=按天 / hour=按小时(北京);区间>1 天建议 day")
|
|
||||||
] = "day",
|
|
||||||
limit: Annotated[int, Query(ge=1, le=1000, description="每页条数(分页大小)")] = 500,
|
|
||||||
offset: Annotated[int, Query(ge=0, description="分页偏移(已跳过条数)=(页码-1)×每页条数")] = 0,
|
|
||||||
sort: Annotated[
|
|
||||||
str, Query(description="排序:time=发起时间倒序(默认) / elapsed=耗时倒序")
|
|
||||||
] = "time",
|
|
||||||
) -> CouponDataOut:
|
|
||||||
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} 天")
|
|
||||||
|
|
||||||
# 报表默认只看 prod(对齐广告报表防串台口径);app_env=all 时不过滤、看全部环境。
|
|
||||||
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,
|
|
||||||
limit=limit, offset=offset, sort=sort,
|
|
||||||
)
|
|
||||||
return CouponDataOut(
|
|
||||||
date_from=d_from.isoformat(),
|
|
||||||
date_to=d_to.isoformat(),
|
|
||||||
summary=CouponDataSummary(**result["summary"]),
|
|
||||||
daily=[CouponDataDaily(**d) for d in result["daily"]],
|
|
||||||
hourly=[CouponDataHourly(**h) for h in result["hourly"]],
|
|
||||||
total=result["total"],
|
|
||||||
items=[CouponDataRow(**r) for r in result["items"]],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/user-records",
|
|
||||||
response_model=CouponUserRecordsOut,
|
|
||||||
summary="某用户全部领券记录(点手机号抽屉:领券次数 + 记录列表)",
|
|
||||||
)
|
|
||||||
def get_user_coupon_records(
|
|
||||||
db: AdminDb,
|
|
||||||
user_id: Annotated[int, Query(description="用户 id")],
|
|
||||||
limit: Annotated[int, Query(ge=1, le=500, description="最多返回条数")] = 100,
|
|
||||||
sort_by: Annotated[str, Query(description="兼容 UserRecordsDrawer 参数;固定按发起时间倒序")] = "created_at",
|
|
||||||
sort_order: Annotated[str, Query(description="兼容参数,忽略")] = "desc",
|
|
||||||
) -> CouponUserRecordsOut:
|
|
||||||
result = coupon_data.coupon_user_records(db, user_id=user_id, limit=limit)
|
|
||||||
return CouponUserRecordsOut(
|
|
||||||
items=[CouponDataRow(**r) for r in result["items"]],
|
|
||||||
total=result["total"],
|
|
||||||
)
|
|
||||||
+16
-48
@@ -1,7 +1,7 @@
|
|||||||
"""admin CPS 分发与对账:群/活动管理 + 生成落地页短链 + 联盟订单对账 + 统计。
|
"""admin CPS 分发与对账:群/活动管理 + 生成落地页短链 + 美团订单对账 + 统计。
|
||||||
|
|
||||||
平台:meituan(actId+sid 转链 + query_order 对账) / taobao(整段淘口令) / jd(链接)。
|
平台:meituan(actId+sid 转链 + query_order 对账) / taobao(整段淘口令) / jd(链接)。
|
||||||
淘宝暂未接 API → 只统计点击(咱落地页 PV/UV + 淘宝复制),对账字段显示 "-"。
|
淘宝/京东无 API → 只统计点击(咱落地页 PV/UV + 淘宝复制),对账字段显示 "-"。
|
||||||
群/活动管理 = operator;订单对账(涉佣金) = finance;只读列表/统计 = 登录即可。
|
群/活动管理 = operator;订单对账(涉佣金) = finance;只读列表/统计 = 登录即可。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -34,7 +34,6 @@ from app.admin.schemas.cps import (
|
|||||||
from app.core import media
|
from app.core import media
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.integrations import meituan
|
from app.integrations import meituan
|
||||||
from app.integrations.jd_union import JdUnionError
|
|
||||||
from app.integrations.meituan import MeituanCpsError
|
from app.integrations.meituan import MeituanCpsError
|
||||||
from app.models.admin import AdminUser
|
from app.models.admin import AdminUser
|
||||||
from app.models.cps_activity import CpsActivity
|
from app.models.cps_activity import CpsActivity
|
||||||
@@ -374,22 +373,7 @@ def _reconcile_range_to_ts(
|
|||||||
return int(start_dt.timestamp()), int(end_dt.timestamp())
|
return int(start_dt.timestamp()), int(end_dt.timestamp())
|
||||||
|
|
||||||
|
|
||||||
def _reconcile_range_to_bj_dt(
|
@router.post("/orders/reconcile", response_model=CpsReconcileResult, summary="拉取美团订单对账")
|
||||||
date_from: _date | None, date_to: _date | None, days: int
|
|
||||||
) -> tuple[datetime, datetime]:
|
|
||||||
start_ts, end_ts = _reconcile_range_to_ts(date_from, date_to, days)
|
|
||||||
return (
|
|
||||||
datetime.fromtimestamp(start_ts, tz=_BEIJING),
|
|
||||||
datetime.fromtimestamp(end_ts, tz=_BEIJING),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_reconcile_result(total: dict, current: dict) -> None:
|
|
||||||
for key in ("fetched", "inserted", "updated", "pages"):
|
|
||||||
total[key] = int(total.get(key, 0)) + int(current.get(key, 0))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/orders/reconcile", response_model=CpsReconcileResult, summary="拉取联盟订单对账")
|
|
||||||
def reconcile_orders(
|
def reconcile_orders(
|
||||||
request: Request,
|
request: Request,
|
||||||
admin: Annotated[AdminUser, Depends(require_role("finance"))],
|
admin: Annotated[AdminUser, Depends(require_role("finance"))],
|
||||||
@@ -398,42 +382,26 @@ def reconcile_orders(
|
|||||||
date_to: Annotated[str | None, Query(description="结束日 YYYY-MM-DD")] = None,
|
date_to: Annotated[str | None, Query(description="结束日 YYYY-MM-DD")] = None,
|
||||||
days: Annotated[int, Query(ge=1, le=90, description="未传日期时默认拉近 N 天")] = 7,
|
days: Annotated[int, Query(ge=1, le=90, description="未传日期时默认拉近 N 天")] = 7,
|
||||||
sid: Annotated[str | None, Query(max_length=64)] = None,
|
sid: Annotated[str | None, Query(max_length=64)] = None,
|
||||||
query_time_type: Annotated[int, Query(ge=1, le=3)] = 1,
|
query_time_type: Annotated[int, Query(ge=1, le=2)] = 1,
|
||||||
platform: Annotated[str, Query(pattern="^(all|meituan|jd)$")] = "all",
|
|
||||||
) -> CpsReconcileResult:
|
) -> CpsReconcileResult:
|
||||||
parsed_from = _parse_day(date_from, field="date_from")
|
start_ts, end_ts = _reconcile_range_to_ts(
|
||||||
parsed_to = _parse_day(date_to, field="date_to")
|
_parse_day(date_from, field="date_from"),
|
||||||
result = {"fetched": 0, "inserted": 0, "updated": 0, "pages": 0}
|
_parse_day(date_to, field="date_to"),
|
||||||
|
days,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
if platform in {"all", "meituan"}:
|
result = cps_repo.reconcile_orders(
|
||||||
start_ts, end_ts = _reconcile_range_to_ts(parsed_from, parsed_to, days)
|
db,
|
||||||
mt_result = cps_repo.reconcile_orders(
|
start_time=start_ts,
|
||||||
db,
|
end_time=end_ts,
|
||||||
start_time=start_ts,
|
query_time_type=query_time_type,
|
||||||
end_time=end_ts,
|
sid=sid,
|
||||||
query_time_type=query_time_type if query_time_type in (1, 2) else 2,
|
)
|
||||||
sid=sid,
|
|
||||||
)
|
|
||||||
_merge_reconcile_result(result, mt_result)
|
|
||||||
if platform in {"all", "jd"}:
|
|
||||||
if sid:
|
|
||||||
raise HTTPException(status_code=422, detail="京东订单刷新不支持 sid 筛选")
|
|
||||||
start_dt, end_dt = _reconcile_range_to_bj_dt(parsed_from, parsed_to, days)
|
|
||||||
jd_result = cps_repo.reconcile_jd_orders(
|
|
||||||
db,
|
|
||||||
start_time=start_dt,
|
|
||||||
end_time=end_dt,
|
|
||||||
query_time_type=query_time_type,
|
|
||||||
)
|
|
||||||
_merge_reconcile_result(result, jd_result)
|
|
||||||
except MeituanCpsError as e:
|
except MeituanCpsError as e:
|
||||||
raise HTTPException(status_code=502, detail=f"美团拉单失败: {e}") from e
|
raise HTTPException(status_code=502, detail=f"美团拉单失败: {e}") from e
|
||||||
except JdUnionError as e:
|
|
||||||
raise HTTPException(status_code=502, detail=f"京东拉单失败: {e}") from e
|
|
||||||
write_audit(
|
write_audit(
|
||||||
db, admin, action="cps.order.reconcile", target_type="cps_order", target_id=None,
|
db, admin, action="cps.order.reconcile", target_type="cps_order", target_id=None,
|
||||||
detail={
|
detail={
|
||||||
"platform": platform,
|
|
||||||
"date_from": date_from,
|
"date_from": date_from,
|
||||||
"date_to": date_to,
|
"date_to": date_to,
|
||||||
"days": days,
|
"days": days,
|
||||||
|
|||||||
@@ -10,12 +10,7 @@ from app.admin.audit import write_audit
|
|||||||
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
||||||
from app.admin.repositories import mutations, queries
|
from app.admin.repositories import mutations, queries
|
||||||
from app.admin.schemas.common import CursorPage, OkResponse
|
from app.admin.schemas.common import CursorPage, OkResponse
|
||||||
from app.admin.schemas.feedback import (
|
from app.admin.schemas.feedback import FeedbackApproveRequest, FeedbackOut, FeedbackRejectRequest
|
||||||
FeedbackApproveRequest,
|
|
||||||
FeedbackOut,
|
|
||||||
FeedbackRejectRequest,
|
|
||||||
FeedbackSummary,
|
|
||||||
)
|
|
||||||
from app.models.admin import AdminUser
|
from app.models.admin import AdminUser
|
||||||
from app.models.feedback import Feedback
|
from app.models.feedback import Feedback
|
||||||
from app.repositories import wallet as wallet_repo
|
from app.repositories import wallet as wallet_repo
|
||||||
@@ -36,8 +31,6 @@ def _ensure_pending(fb: Feedback) -> None:
|
|||||||
def list_feedbacks(
|
def list_feedbacks(
|
||||||
db: AdminDb,
|
db: AdminDb,
|
||||||
status: Annotated[str | None, Query()] = None,
|
status: Annotated[str | None, Query()] = None,
|
||||||
# 反馈类型筛选:profile(普通反馈)/ comparison(比价反馈);None=全部
|
|
||||||
source: Annotated[str | None, Query(pattern="^(profile|comparison)$")] = None,
|
|
||||||
user_id: Annotated[int | None, Query()] = None,
|
user_id: Annotated[int | None, Query()] = None,
|
||||||
content: Annotated[str | None, Query(max_length=100)] = None,
|
content: Annotated[str | None, Query(max_length=100)] = None,
|
||||||
created_from: Annotated[datetime | None, Query()] = None,
|
created_from: Annotated[datetime | None, Query()] = None,
|
||||||
@@ -50,7 +43,6 @@ def list_feedbacks(
|
|||||||
items, next_cursor, total = queries.list_feedbacks(
|
items, next_cursor, total = queries.list_feedbacks(
|
||||||
db,
|
db,
|
||||||
status=status,
|
status=status,
|
||||||
source=source,
|
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
content=content,
|
content=content,
|
||||||
created_from=created_from,
|
created_from=created_from,
|
||||||
@@ -67,11 +59,6 @@ def list_feedbacks(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/summary", response_model=FeedbackSummary, summary="反馈审核统计(各状态计数)")
|
|
||||||
def feedback_summary(db: AdminDb) -> FeedbackSummary:
|
|
||||||
return FeedbackSummary.model_validate(queries.feedback_summary(db))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{feedback_id}/handle", response_model=OkResponse, summary="标记反馈已处理")
|
@router.post("/{feedback_id}/handle", response_model=OkResponse, summary="标记反馈已处理")
|
||||||
def handle_feedback(
|
def handle_feedback(
|
||||||
feedback_id: int,
|
feedback_id: int,
|
||||||
@@ -104,7 +91,6 @@ def approve_feedback(
|
|||||||
status="adopted",
|
status="adopted",
|
||||||
reward_coins=payload.reward_coins,
|
reward_coins=payload.reward_coins,
|
||||||
review_note=payload.note,
|
review_note=payload.note,
|
||||||
admin_reply=payload.reply,
|
|
||||||
reviewed_by_admin_id=admin.id,
|
reviewed_by_admin_id=admin.id,
|
||||||
commit=False,
|
commit=False,
|
||||||
)
|
)
|
||||||
@@ -127,7 +113,6 @@ def approve_feedback(
|
|||||||
"after": "adopted",
|
"after": "adopted",
|
||||||
"reward_coins": payload.reward_coins,
|
"reward_coins": payload.reward_coins,
|
||||||
"note": payload.note,
|
"note": payload.note,
|
||||||
"reply": payload.reply,
|
|
||||||
},
|
},
|
||||||
ip=get_client_ip(request),
|
ip=get_client_ip(request),
|
||||||
commit=False,
|
commit=False,
|
||||||
@@ -157,7 +142,6 @@ def reject_feedback(
|
|||||||
status="rejected",
|
status="rejected",
|
||||||
reject_reason=payload.reason,
|
reject_reason=payload.reason,
|
||||||
review_note=payload.note,
|
review_note=payload.note,
|
||||||
admin_reply=payload.reply,
|
|
||||||
reviewed_by_admin_id=admin.id,
|
reviewed_by_admin_id=admin.id,
|
||||||
commit=False,
|
commit=False,
|
||||||
)
|
)
|
||||||
@@ -172,7 +156,6 @@ def reject_feedback(
|
|||||||
"after": "rejected",
|
"after": "rejected",
|
||||||
"reason": payload.reason,
|
"reason": payload.reason,
|
||||||
"note": payload.note,
|
"note": payload.note,
|
||||||
"reply": payload.reply,
|
|
||||||
},
|
},
|
||||||
ip=get_client_ip(request),
|
ip=get_client_ip(request),
|
||||||
commit=False,
|
commit=False,
|
||||||
|
|||||||
@@ -37,14 +37,11 @@ def list_price_reports(
|
|||||||
db: AdminDb,
|
db: AdminDb,
|
||||||
status: Annotated[str | None, Query()] = None,
|
status: Annotated[str | None, Query()] = None,
|
||||||
user_id: Annotated[int | None, Query()] = None,
|
user_id: Annotated[int | None, Query()] = None,
|
||||||
sort_by: Annotated[str, Query(pattern="^(id|created_at)$")] = "id",
|
|
||||||
sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc",
|
|
||||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||||
cursor: Annotated[int | None, Query()] = None,
|
cursor: Annotated[int | None, Query()] = None,
|
||||||
) -> CursorPage[PriceReportOut]:
|
) -> CursorPage[PriceReportOut]:
|
||||||
items, next_cursor, total = queries.list_price_reports(
|
items, next_cursor, total = queries.list_price_reports(
|
||||||
db, status=status, user_id=user_id,
|
db, status=status, user_id=user_id, limit=limit, cursor=cursor,
|
||||||
sort_by=sort_by, sort_order=sort_order, limit=limit, cursor=cursor,
|
|
||||||
)
|
)
|
||||||
return CursorPage(
|
return CursorPage(
|
||||||
items=[PriceReportOut.model_validate(r) for r in items],
|
items=[PriceReportOut.model_validate(r) for r in items],
|
||||||
|
|||||||
+11
-19
@@ -210,28 +210,22 @@ def grant_user_cash(
|
|||||||
db: AdminDb,
|
db: AdminDb,
|
||||||
) -> OkResponse:
|
) -> OkResponse:
|
||||||
"""给指定用户增/减或设值现金(分)。delta:正=发放、负=扣减;set:直接设为目标值。
|
"""给指定用户增/减或设值现金(分)。delta:正=发放、负=扣减;set:直接设为目标值。
|
||||||
account=coin_cash(金币兑现金)/ invite_cash(邀请奖励金):两本账物理隔离、各调各的。
|
|
||||||
主要用于让无现金用户直接测试提现。"""
|
主要用于让无现金用户直接测试提现。"""
|
||||||
user = user_repo.get_user_by_id(db, user_id)
|
user = user_repo.get_user_by_id(db, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=404, detail="用户不存在")
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
# 按目标账户选「余额字段 + 变动入口」(grant_invite_cash 与 grant_cash 同构)
|
# set=设为目标值:读当前余额算差值,仍复用 grant_cash 写一笔流水(沿用原子/审计/扣负保护)
|
||||||
is_invite = body.account == "invite_cash"
|
|
||||||
balance_attr = "invite_cash_balance_cents" if is_invite else "cash_balance_cents"
|
|
||||||
grant_fn = wallet_repo.grant_invite_cash if is_invite else wallet_repo.grant_cash
|
|
||||||
acct_label = "邀请奖励金" if is_invite else "现金"
|
|
||||||
before: int | None = None
|
|
||||||
# set=设为目标值:读当前余额算差值,仍复用 grant 写一笔流水(沿用原子/审计/扣负保护)
|
|
||||||
if body.mode == "set":
|
if body.mode == "set":
|
||||||
if body.amount_cents < 0:
|
if body.amount_cents < 0:
|
||||||
raise HTTPException(status_code=400, detail=f"目标{acct_label}值不能为负")
|
raise HTTPException(status_code=400, detail="目标现金值不能为负")
|
||||||
# lock=True:锁账户行,防连点/并发各读同一 before 算同一 delta 双写,余额错位
|
# lock=True:锁账户行,防连点/并发各读同一 before 算同一 delta 双写,余额错位
|
||||||
acc_locked = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True)
|
before = wallet_repo.get_or_create_account(
|
||||||
before = getattr(acc_locked, balance_attr)
|
db, user_id, commit=False, lock=True
|
||||||
|
).cash_balance_cents
|
||||||
delta = body.amount_cents - before
|
delta = body.amount_cents - before
|
||||||
if delta == 0:
|
if delta == 0:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400, detail=f"当前{acct_label}已为 {body.amount_cents} 分,无需调整"
|
status_code=400, detail=f"当前现金已为 {body.amount_cents} 分,无需调整"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if body.amount_cents == 0:
|
if body.amount_cents == 0:
|
||||||
@@ -240,20 +234,18 @@ def grant_user_cash(
|
|||||||
# 负数扣减时不允许扣成负余额(运营误操作保护);lock=True 防并发扣穿
|
# 负数扣减时不允许扣成负余额(运营误操作保护);lock=True 防并发扣穿
|
||||||
if delta < 0:
|
if delta < 0:
|
||||||
acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True)
|
acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True)
|
||||||
if getattr(acc_now, balance_attr) + delta < 0:
|
if acc_now.cash_balance_cents + delta < 0:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400, detail=f"扣减后现金为负(当前余额 {acc_now.cash_balance_cents} 分)"
|
||||||
detail=f"扣减后{acct_label}为负(当前余额 {getattr(acc_now, balance_attr)} 分)",
|
|
||||||
)
|
)
|
||||||
biz_type = "admin_grant" if delta > 0 else "admin_deduct"
|
biz_type = "admin_grant" if delta > 0 else "admin_deduct"
|
||||||
# grant 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕)
|
# grant_cash 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕)
|
||||||
acc, _ = grant_fn(
|
acc, _ = wallet_repo.grant_cash(
|
||||||
db, user_id, delta, biz_type=biz_type, remark=f"admin:{body.reason}"[:128],
|
db, user_id, delta, biz_type=biz_type, remark=f"admin:{body.reason}"[:128],
|
||||||
)
|
)
|
||||||
detail = {
|
detail = {
|
||||||
"account": body.account,
|
|
||||||
"amount_cents": delta,
|
"amount_cents": delta,
|
||||||
"balance_after_cents": getattr(acc, balance_attr),
|
"balance_after_cents": acc.cash_balance_cents,
|
||||||
"reason": body.reason,
|
"reason": body.reason,
|
||||||
}
|
}
|
||||||
if body.mode == "set":
|
if body.mode == "set":
|
||||||
|
|||||||
@@ -50,8 +50,6 @@ def list_withdraws(
|
|||||||
db: AdminDb,
|
db: AdminDb,
|
||||||
user_id: Annotated[int | None, Query()] = None,
|
user_id: Annotated[int | None, Query()] = None,
|
||||||
status: Annotated[str | None, Query()] = None,
|
status: Annotated[str | None, Query()] = None,
|
||||||
# 提现类型筛选:coin_cash(福利页提现)/ invite_cash(邀请提现);None=全部
|
|
||||||
source: Annotated[str | None, Query(pattern="^(coin_cash|invite_cash)$")] = None,
|
|
||||||
keyword: Annotated[str | None, Query(max_length=100)] = None,
|
keyword: Annotated[str | None, Query(max_length=100)] = None,
|
||||||
date_from: Annotated[datetime | None, Query()] = None,
|
date_from: Annotated[datetime | None, Query()] = None,
|
||||||
date_to: Annotated[datetime | None, Query()] = None,
|
date_to: Annotated[datetime | None, Query()] = None,
|
||||||
@@ -71,7 +69,6 @@ def list_withdraws(
|
|||||||
db,
|
db,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
status=status,
|
status=status,
|
||||||
source=source,
|
|
||||||
keyword=keyword,
|
keyword=keyword,
|
||||||
date_from=date_from,
|
date_from=date_from,
|
||||||
date_to=date_to,
|
date_to=date_to,
|
||||||
|
|||||||
@@ -44,13 +44,7 @@ class AdRevenueDaily(BaseModel):
|
|||||||
|
|
||||||
date: str = Field(..., description="北京时间 YYYY-MM-DD")
|
date: str = Field(..., description="北京时间 YYYY-MM-DD")
|
||||||
impressions: int = Field(..., description="当天展示条数合计")
|
impressions: int = Field(..., description="当天展示条数合计")
|
||||||
revenue_yuan: float = Field(..., description="当天客户端预估收益合计(元;eCPM 折算)")
|
revenue_yuan: float = Field(..., description="当天预估收益合计(元)")
|
||||||
pangle_revenue_yuan: float | None = Field(
|
|
||||||
None, description="当天穿山甲后台预估收益(元;GroMore revenue);非全量视图/无数据为空"
|
|
||||||
)
|
|
||||||
pangle_api_revenue_yuan: float | None = Field(
|
|
||||||
None, description="当天穿山甲收益Api(元;GroMore api_revenue,更接近结算);未配/当天/无数据为空"
|
|
||||||
)
|
|
||||||
expected_coin: int = Field(..., description="当天应发金币合计")
|
expected_coin: int = Field(..., description="当天应发金币合计")
|
||||||
actual_coin: int = Field(..., description="当天实发金币合计")
|
actual_coin: int = Field(..., description="当天实发金币合计")
|
||||||
|
|
||||||
@@ -94,11 +88,6 @@ class AdRevenueRow(BaseModel):
|
|||||||
impressions: int = Field(..., description="本行展示条数:有展示=1 / 纯发奖=0(供日汇总、趋势图复用)")
|
impressions: int = Field(..., description="本行展示条数:有展示=1 / 纯发奖=0(供日汇总、趋势图复用)")
|
||||||
ecpm: str | None = Field(None, description="eCPM 原始值(分/千次);展示行取展示值,纯发奖行取发奖采用值")
|
ecpm: str | None = Field(None, description="eCPM 原始值(分/千次);展示行取展示值,纯发奖行取发奖采用值")
|
||||||
revenue_yuan: float = Field(..., description="本次展示预估收益(元)= eCPM元 ÷ 1000;纯发奖行=0")
|
revenue_yuan: float = Field(..., description="本次展示预估收益(元)= eCPM元 ÷ 1000;纯发奖行=0")
|
||||||
row_revenue_yuan: float | None = Field(
|
|
||||||
None,
|
|
||||||
description="主表逐行展示用的预估收益(元):一次比价/领券聚合行=该次发奖广告 eCPM 折算之和;"
|
|
||||||
"其它行为空(前端回退取 revenue_yuan)。不进合计/趋势,避免与展示侧重复计",
|
|
||||||
)
|
|
||||||
adn: str | None = Field(None, description="实际填充 ADN 子渠道(pangle/gdt…);纯发奖行为空")
|
adn: str | None = Field(None, description="实际填充 ADN 子渠道(pangle/gdt…);纯发奖行为空")
|
||||||
slot_id: str | None = Field(None, description="底层 mediation rit(非我们配置的广告位 ID);纯发奖行为空")
|
slot_id: str | None = Field(None, description="底层 mediation rit(非我们配置的广告位 ID);纯发奖行为空")
|
||||||
# ── 发奖侧 ──
|
# ── 发奖侧 ──
|
||||||
@@ -111,15 +100,6 @@ class AdRevenueRow(BaseModel):
|
|||||||
None,
|
None,
|
||||||
description="发奖复算明细(eCPM/因子1/份数/LT/因子2/应发/实发/一致);点行展开下钻用,纯展示为空",
|
description="发奖复算明细(eCPM/因子1/份数/LT/因子2/应发/实发/一致);点行展开下钻用,纯展示为空",
|
||||||
)
|
)
|
||||||
sub_rewards: list[AdRevenueRecord] = Field(
|
|
||||||
default_factory=list,
|
|
||||||
description="一次比价/领券聚合行的组内逐条发奖明细(同一整场 ad_session_id 的多条广告);"
|
|
||||||
"点行展开渲染多行。激励视频/纯展示行为空(单条看 reward_detail)",
|
|
||||||
)
|
|
||||||
sub_count: int = Field(
|
|
||||||
1,
|
|
||||||
description="本行聚合的发奖条数:一次比价/领券=该次广告条数(≥1);激励视频/纯展示=1",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AdRevenueReportOut(BaseModel):
|
class AdRevenueReportOut(BaseModel):
|
||||||
@@ -143,21 +123,7 @@ class AdRevenueReportOut(BaseModel):
|
|||||||
total: int = Field(..., description="广告事件总数(全量,不受分页影响;= 当前筛选下的分页总条数)")
|
total: int = Field(..., description="广告事件总数(全量,不受分页影响;= 当前筛选下的分页总条数)")
|
||||||
truncated: bool = Field(..., description="当前页之后是否还有更多事件(len(events) > offset + limit)")
|
truncated: bool = Field(..., description="当前页之后是否还有更多事件(len(events) > offset + limit)")
|
||||||
total_impressions: int = Field(..., description="全量展示条数合计")
|
total_impressions: int = Field(..., description="全量展示条数合计")
|
||||||
total_revenue_yuan: float = Field(..., description="全量客户端预估收益合计(元;eCPM 折算)")
|
total_revenue_yuan: float = Field(..., description="全量收益合计(元)")
|
||||||
total_pangle_revenue_yuan: float | None = Field(
|
|
||||||
None,
|
|
||||||
description="全量穿山甲后台预估收益合计(元;GroMore revenue)。穿山甲无用户/类型/场景维度,"
|
|
||||||
"仅「全量视图」(未按 user_id/ad_type/feed_scene 过滤)时有值,否则为 null",
|
|
||||||
)
|
|
||||||
total_pangle_api_revenue_yuan: float | None = Field(
|
|
||||||
None,
|
|
||||||
description="全量穿山甲收益Api合计(元;GroMore api_revenue,各 ADN 回传、更接近结算);"
|
|
||||||
"未配 Reporting / 查当天 / 非全量视图 时为 null",
|
|
||||||
)
|
|
||||||
pangle_revenue_available: bool = Field(
|
|
||||||
False,
|
|
||||||
description="本次结果是否带穿山甲后台收益(=全量视图且已同步到数据)。false 时前端「穿山甲收益」显示「-」",
|
|
||||||
)
|
|
||||||
total_expected_coin: int = Field(..., description="全量应发金币合计")
|
total_expected_coin: int = Field(..., description="全量应发金币合计")
|
||||||
total_actual_coin: int = Field(..., description="全量实发金币合计")
|
total_actual_coin: int = Field(..., description="全量实发金币合计")
|
||||||
mismatch_count: int = Field(..., description="应发≠实发的发奖条数(=0 说明全部按公式发放)")
|
mismatch_count: int = Field(..., description="应发≠实发的发奖条数(=0 说明全部按公式发放)")
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
"""admin「领券数据」看板 schemas:汇总卡 + 按天/小时趋势 + 逐条领券明细。
|
|
||||||
|
|
||||||
数据源 coupon_session(一次领券一行)。耗时单位 ms(前端按需折秒);均值/分位只统计 completed。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class CouponDataSummary(BaseModel):
|
|
||||||
"""汇总卡:发起/完成数 + 耗时均值与分位(P5/P50/P95/P99,基于 completed 的 elapsed_ms)。"""
|
|
||||||
|
|
||||||
started_count: int = Field(..., description="发起数(区间内所有领券 session)")
|
|
||||||
completed_count: int = Field(..., description="完成数(status=completed)")
|
|
||||||
avg_elapsed_ms: int | None = Field(None, description="平均耗时(ms,仅 completed;无数据为空)")
|
|
||||||
p5_ms: int | None = Field(None, description="耗时 5 分位(ms)")
|
|
||||||
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)")
|
|
||||||
|
|
||||||
|
|
||||||
class CouponDataDaily(BaseModel):
|
|
||||||
"""按天趋势(全量,不受分页影响):柱=发起/完成数,线=平均耗时。"""
|
|
||||||
|
|
||||||
date: str = Field(..., description="北京时间 YYYY-MM-DD")
|
|
||||||
started_count: int
|
|
||||||
completed_count: int
|
|
||||||
avg_elapsed_ms: int | None = Field(None, description="当天平均耗时(ms,仅 completed)")
|
|
||||||
|
|
||||||
|
|
||||||
class CouponDataHourly(BaseModel):
|
|
||||||
"""按北京小时(0–23)趋势(单日 granularity=hour 时非空)。"""
|
|
||||||
|
|
||||||
hour: int = Field(..., description="北京时间小时 0–23")
|
|
||||||
started_count: int
|
|
||||||
completed_count: int
|
|
||||||
avg_elapsed_ms: int | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class CouponDataRow(BaseModel):
|
|
||||||
"""一条领券明细(一次领券任务)。"""
|
|
||||||
|
|
||||||
id: int = Field(..., description="coupon_session 主键(抽屉 rowKey 用)")
|
|
||||||
trace_id: str
|
|
||||||
user_id: int | None = None
|
|
||||||
user_phone: str | None = Field(None, description="手机号(admin 展示;匿名领券/查不到为空)")
|
|
||||||
user_nickname: str | None = Field(None, description="昵称")
|
|
||||||
status: str = Field(..., description="started / completed / failed / abandoned")
|
|
||||||
platforms: list[str] | None = Field(None, description="发起勾选平台")
|
|
||||||
origin_package: str | None = Field(None, description="发起来源 App 包名;null=App 内(傻瓜比价首页)发起")
|
|
||||||
elapsed_ms: int | None = Field(None, description="全程耗时(ms)")
|
|
||||||
platform_elapsed: dict[str, int] | None = Field(
|
|
||||||
None, description="各平台耗时 {meituan-waimai/taobao-shanguang/jd-waimai: ms}"
|
|
||||||
)
|
|
||||||
device_model: str | None = None
|
|
||||||
rom: str | None = None
|
|
||||||
app_env: str | None = None
|
|
||||||
started_at: datetime = Field(..., description="发起时刻(明细「时间」列)")
|
|
||||||
claimed_count: int | None = None
|
|
||||||
trace_url: str | None = Field(None, description="pricebot 公网 trace 链接(仅 completed 有);admin 渲染可点链接,无则显示可复制 trace_id")
|
|
||||||
|
|
||||||
|
|
||||||
class CouponDataOut(BaseModel):
|
|
||||||
"""领券数据看板响应:汇总卡 + 趋势 + 明细分页。"""
|
|
||||||
|
|
||||||
date_from: str
|
|
||||||
date_to: str
|
|
||||||
summary: CouponDataSummary
|
|
||||||
daily: list[CouponDataDaily] = Field(default_factory=list, description="按天趋势(全量)")
|
|
||||||
hourly: list[CouponDataHourly] = Field(
|
|
||||||
default_factory=list, description="按小时趋势(单日 hour 粒度时非空)"
|
|
||||||
)
|
|
||||||
total: int = Field(..., description="明细总条数(全量,不受分页)")
|
|
||||||
items: list[CouponDataRow] = Field(..., description="逐条领券明细(当前页)")
|
|
||||||
|
|
||||||
|
|
||||||
class CouponUserRecordsOut(BaseModel):
|
|
||||||
"""某用户全部领券记录(点手机号抽屉用):total=该用户领券总次数,items=记录列表(UserRecordsDrawer 渲染)。"""
|
|
||||||
|
|
||||||
items: list[CouponDataRow]
|
|
||||||
total: int
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""admin CPS 分发与对账 schemas。金额统一「分」(cents),前端 yuan() 展示。
|
"""admin CPS 分发与对账 schemas。金额统一「分」(cents),前端 yuan() 展示。
|
||||||
|
|
||||||
平台:meituan(actId+sid 转链对账) / taobao(淘口令,只统计点击) / jd(链接 + 订单 API 对账)。
|
平台:meituan(actId+sid 转链对账) / taobao(淘口令,只统计点击) / jd(链接,只统计点击)。
|
||||||
对账类字段对淘宝为 None → 前端显示 "-"(暂未对账)。
|
对账类字段对淘宝/京东为 None → 前端显示 "-"(无法对账)。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -116,22 +116,15 @@ class CpsOrderOut(BaseModel):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
platform: str = "meituan"
|
|
||||||
order_id: str
|
order_id: str
|
||||||
external_order_id: str | None = None
|
|
||||||
external_row_id: str | None = None
|
|
||||||
sid: str | None = None
|
sid: str | None = None
|
||||||
act_id: str | None = None
|
act_id: str | None = None
|
||||||
pay_price_cents: int | None = None
|
pay_price_cents: int | None = None
|
||||||
commission_cents: int | None = None
|
commission_cents: int | None = None
|
||||||
estimated_commission_cents: int | None = None
|
|
||||||
actual_commission_cents: int | None = None
|
|
||||||
commission_rate: str | None = None
|
commission_rate: str | None = None
|
||||||
mt_status: str | None = None
|
mt_status: str | None = None
|
||||||
jd_valid_code: str | None = None
|
|
||||||
invalid_reason: str | None = None
|
invalid_reason: str | None = None
|
||||||
product_name: str | None = None
|
product_name: str | None = None
|
||||||
settle_month: str | None = None
|
|
||||||
pay_time: datetime | None = None
|
pay_time: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -103,11 +103,6 @@ class DashboardCps(BaseModel):
|
|||||||
meituan_miss_count: int = 0
|
meituan_miss_count: int = 0
|
||||||
meituan_unknown_rate_count: int = 0
|
meituan_unknown_rate_count: int = 0
|
||||||
meituan_hit_rate: float | None = None
|
meituan_hit_rate: float | None = None
|
||||||
jd_order_count: int = 0
|
|
||||||
jd_commission_cents: int = 0
|
|
||||||
jd_actual_commission_cents: int = 0
|
|
||||||
jd_estimated_commission_cents: int = 0
|
|
||||||
jd_invalid_count: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class DashboardOverview(BaseModel):
|
class DashboardOverview(BaseModel):
|
||||||
|
|||||||
@@ -15,28 +15,14 @@ class FeedbackOut(BaseModel):
|
|||||||
user_id: int
|
user_id: int
|
||||||
content: str
|
content: str
|
||||||
contact: str
|
contact: str
|
||||||
# 反馈来源入口:profile(「我的」页)/ comparison(比价结果页)——admin 展示/筛选「反馈类型」
|
|
||||||
source: str = "profile"
|
|
||||||
# 比价反馈的问题场景(找错商品/优惠不对…);普通反馈为 None
|
|
||||||
scene: str | None = None
|
|
||||||
images: list[str] | None = None
|
images: list[str] | None = None
|
||||||
status: str
|
status: str
|
||||||
reject_reason: str | None = None
|
reject_reason: str | None = None
|
||||||
reward_coins: int | None = None
|
reward_coins: int | None = None
|
||||||
review_note: str | None = None
|
review_note: str | None = None
|
||||||
# 运营给用户的回复留言(用户端可见,采纳/未采纳都可填)
|
|
||||||
admin_reply: str | None = None
|
|
||||||
reviewed_by_admin_id: int | None = None
|
reviewed_by_admin_id: int | None = None
|
||||||
reviewed_at: datetime | None = None
|
reviewed_at: datetime | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
# 提交端环境快照(feedback 表列):提交版本号 / 机型OS版本;改版前的历史反馈为 None
|
|
||||||
app_version: str | None = None
|
|
||||||
device_model: str | None = None
|
|
||||||
rom_name: str | None = None
|
|
||||||
android_version: str | None = None
|
|
||||||
# 联表瞬态字段(queries._attach_user_info 挂):列表展示完整手机号,点手机号查该用户全部反馈
|
|
||||||
phone: str | None = None
|
|
||||||
nickname: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class FeedbackApproveRequest(BaseModel):
|
class FeedbackApproveRequest(BaseModel):
|
||||||
@@ -45,20 +31,9 @@ class FeedbackApproveRequest(BaseModel):
|
|||||||
le=FEEDBACK_REWARD_MAX_COINS,
|
le=FEEDBACK_REWARD_MAX_COINS,
|
||||||
description="采纳后发放金币数",
|
description="采纳后发放金币数",
|
||||||
)
|
)
|
||||||
note: str | None = Field(default=None, max_length=256, description="采纳要点/审核备注(内部)")
|
note: str | None = Field(default=None, max_length=256, description="采纳要点/审核备注")
|
||||||
reply: str | None = Field(default=None, max_length=256, description="给用户的回复留言,用户端可见")
|
|
||||||
|
|
||||||
|
|
||||||
class FeedbackRejectRequest(BaseModel):
|
class FeedbackRejectRequest(BaseModel):
|
||||||
reason: str = Field(min_length=1, max_length=256, description="未采纳原因,用户端可见")
|
reason: str = Field(min_length=1, max_length=256, description="未采纳原因,用户端可见")
|
||||||
note: str | None = Field(default=None, max_length=256, description="运营内部审核备注")
|
note: str | None = Field(default=None, max_length=256, description="运营内部审核备注")
|
||||||
reply: str | None = Field(default=None, max_length=256, description="给用户的回复留言,用户端可见")
|
|
||||||
|
|
||||||
|
|
||||||
class FeedbackSummary(BaseModel):
|
|
||||||
"""审核台顶部各状态计数(pending 含历史 new 态)。"""
|
|
||||||
|
|
||||||
pending: int
|
|
||||||
adopted: int
|
|
||||||
rejected: int
|
|
||||||
total: int
|
|
||||||
|
|||||||
@@ -31,17 +31,6 @@ class PriceReportOut(BaseModel):
|
|||||||
reward_coins: int | None = None
|
reward_coins: int | None = None
|
||||||
reviewed_at: datetime | None = None
|
reviewed_at: datetime | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
# 联表瞬态字段:phone/nickname 由 _attach_user_info 挂(展示完整手机号、点击查该用户全部上报);
|
|
||||||
# 其余由 _attach_price_report_comparison 从关联比价记录挂:trace_*(Trace 列点跳调试链接)、
|
|
||||||
# device_model/rom_name/android_version(机型OS版本列)、app_version(提交版本号列)
|
|
||||||
phone: str | None = None
|
|
||||||
nickname: str | None = None
|
|
||||||
trace_id: str | None = None
|
|
||||||
trace_url: str | None = None
|
|
||||||
device_model: str | None = None
|
|
||||||
rom_name: str | None = None
|
|
||||||
android_version: str | None = None
|
|
||||||
app_version: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class PriceReportRejectRequest(BaseModel):
|
class PriceReportRejectRequest(BaseModel):
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ class AdminUserListItem(BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
debug_trace_enabled: bool = False
|
debug_trace_enabled: bool = False
|
||||||
wechat_openid: str | None = None
|
wechat_openid: str | None = None
|
||||||
wechat_nickname: str | None = None
|
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
last_login_at: datetime
|
last_login_at: datetime
|
||||||
|
|
||||||
@@ -30,7 +29,6 @@ class AdminUserOverview(BaseModel):
|
|||||||
user: AdminUserListItem
|
user: AdminUserListItem
|
||||||
coin_balance: int
|
coin_balance: int
|
||||||
cash_balance_cents: int
|
cash_balance_cents: int
|
||||||
invite_cash_balance_cents: int # 邀请奖励金余额(与 cash_balance_cents 物理隔离)
|
|
||||||
total_coin_earned: int
|
total_coin_earned: int
|
||||||
comparison_total: int
|
comparison_total: int
|
||||||
comparison_success: int
|
comparison_success: int
|
||||||
@@ -90,11 +88,6 @@ class GrantCoinsRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class GrantCashRequest(BaseModel):
|
class GrantCashRequest(BaseModel):
|
||||||
# 目标账户:金币兑换的现金(cash_balance_cents)与邀请奖励金(invite_cash_balance_cents)物理隔离,
|
|
||||||
# 各调各的、不可串。默认 coin_cash 兼容旧调用。
|
|
||||||
account: Literal["coin_cash", "invite_cash"] = Field(
|
|
||||||
"coin_cash", description="目标账户:coin_cash=金币兑现金账户 / invite_cash=邀请奖励金账户"
|
|
||||||
)
|
|
||||||
mode: Literal["delta", "set"] = Field(
|
mode: Literal["delta", "set"] = Field(
|
||||||
"delta", description="delta=增减(amount_cents 为变动量) / set=设为(amount_cents 为目标值,须≥0)"
|
"delta", description="delta=增减(amount_cents 为变动量) / set=设为(amount_cents 为目标值,须≥0)"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ class WithdrawOrderOut(BaseModel):
|
|||||||
user_id: int
|
user_id: int
|
||||||
out_bill_no: str
|
out_bill_no: str
|
||||||
amount_cents: int
|
amount_cents: int
|
||||||
# 提现类型:coin_cash(福利页提现,金币兑换的现金)/ invite_cash(邀请提现,邀请奖励金)
|
|
||||||
source: str = "coin_cash"
|
|
||||||
user_name: str | None = None # 提现实名(审核核对 + 打款用)
|
user_name: str | None = None # 提现实名(审核核对 + 打款用)
|
||||||
status: str
|
status: str
|
||||||
wechat_state: str | None = None
|
wechat_state: str | None = None
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ from app.schemas.coupon_state import (
|
|||||||
CouponPromptDismissIn,
|
CouponPromptDismissIn,
|
||||||
CouponPromptShouldShowOut,
|
CouponPromptShouldShowOut,
|
||||||
CouponPromptShownIn,
|
CouponPromptShownIn,
|
||||||
CouponSessionIn,
|
|
||||||
CouponStatsOut,
|
CouponStatsOut,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -195,34 +194,6 @@ async def coupon_step(
|
|||||||
return resp_json
|
return resp_json
|
||||||
|
|
||||||
|
|
||||||
@router.post("/session", summary="领券任务流水上报(admin 领券数据看板数据源)")
|
|
||||||
def coupon_session(payload: CouponSessionIn, db: DbSession) -> dict[str, bool]:
|
|
||||||
"""客户端两段上报一次领券流水(发起 started / 收尾 completed-failed-abandoned),按 trace_id upsert
|
|
||||||
到 coupon_session。不鉴权(同领券循环 MVP,按 device_id/trace_id);供 admin「领券数据」看板算
|
|
||||||
发起/完成数、耗时分位、机型维度。写库失败不应连累客户端(本就 fire-and-forget),吞掉返回 ok。"""
|
|
||||||
try:
|
|
||||||
coupon_repo.upsert_coupon_session(
|
|
||||||
db,
|
|
||||||
trace_id=payload.trace_id,
|
|
||||||
device_id=payload.device_id,
|
|
||||||
status=payload.status,
|
|
||||||
started_at_ms=payload.started_at_ms,
|
|
||||||
user_id=payload.user_id,
|
|
||||||
platforms=payload.platforms,
|
|
||||||
origin_package=payload.origin_package,
|
|
||||||
device_model=payload.device_model,
|
|
||||||
rom=payload.rom,
|
|
||||||
app_env=payload.app_env,
|
|
||||||
elapsed_ms=payload.elapsed_ms,
|
|
||||||
platform_elapsed=payload.platform_elapsed,
|
|
||||||
claimed_count=payload.claimed_count,
|
|
||||||
trace_url=payload.trace_url,
|
|
||||||
)
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.warning("coupon session write failed: %s", e)
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/prompt/shown", summary="领券引导窗弹出即上报(按 App 记 shown)")
|
@router.post("/prompt/shown", summary="领券引导窗弹出即上报(按 App 记 shown)")
|
||||||
def coupon_prompt_shown(payload: CouponPromptShownIn, db: DbSession) -> dict[str, bool]:
|
def coupon_prompt_shown(payload: CouponPromptShownIn, db: DbSession) -> dict[str, bool]:
|
||||||
"""客户端弹出引导窗那刻调 → 记一条今日 engagement(shown),今天**这个 App** 不再自动弹。
|
"""客户端弹出引导窗那刻调 → 记一条今日 engagement(shown),今天**这个 App** 不再自动弹。
|
||||||
|
|||||||
+1
-31
@@ -31,18 +31,7 @@ router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"])
|
|||||||
_MAX_IMAGES = 6
|
_MAX_IMAGES = 6
|
||||||
_CONTENT_MAX = 200
|
_CONTENT_MAX = 200
|
||||||
_CONTACT_MAX = 128
|
_CONTACT_MAX = 128
|
||||||
_SCENE_MAX = 32
|
|
||||||
_VALID_RECORD_STATUS = {"pending", "adopted", "rejected"}
|
_VALID_RECORD_STATUS = {"pending", "adopted", "rejected"}
|
||||||
_VALID_SOURCES = {"profile", "comparison"}
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_source(source: str, scene: str) -> str:
|
|
||||||
"""反馈来源:客户端显式传的 source 优先(profile / comparison);未传或非法时,
|
|
||||||
按 scene 有无派生——比价结果页反馈才带 scene,故 scene 非空即视作 comparison。"""
|
|
||||||
src = source.strip().lower()
|
|
||||||
if src in _VALID_SOURCES:
|
|
||||||
return src
|
|
||||||
return "comparison" if scene.strip() else "profile"
|
|
||||||
|
|
||||||
|
|
||||||
def _app_status(db_status: str) -> str:
|
def _app_status(db_status: str) -> str:
|
||||||
@@ -57,12 +46,10 @@ def _record_out(fb) -> FeedbackRecordOut:
|
|||||||
return FeedbackRecordOut(
|
return FeedbackRecordOut(
|
||||||
id=fb.id,
|
id=fb.id,
|
||||||
content=fb.content,
|
content=fb.content,
|
||||||
scene=getattr(fb, "scene", None),
|
|
||||||
images=fb.images or [],
|
images=fb.images or [],
|
||||||
status=_app_status(fb.status),
|
status=_app_status(fb.status),
|
||||||
reject_reason=getattr(fb, "reject_reason", None),
|
reject_reason=getattr(fb, "reject_reason", None),
|
||||||
reward_coins=getattr(fb, "reward_coins", None),
|
reward_coins=getattr(fb, "reward_coins", None),
|
||||||
admin_reply=getattr(fb, "admin_reply", None),
|
|
||||||
created_at=fb.created_at,
|
created_at=fb.created_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -74,16 +61,6 @@ async def submit_feedback(
|
|||||||
content: str = Form(...),
|
content: str = Form(...),
|
||||||
# 原型改版后客户端不再采集联系方式;保留字段以兼容旧端 + 后续可能复用,默认空串。
|
# 原型改版后客户端不再采集联系方式;保留字段以兼容旧端 + 后续可能复用,默认空串。
|
||||||
contact: str = Form(default=""),
|
contact: str = Form(default=""),
|
||||||
# 反馈来源入口:profile(「我的」页)/ comparison(比价结果页)。新端显式传;旧端不带 →
|
|
||||||
# 空串 → 按 scene 有无派生。admin 据此展示/筛选「反馈类型」。
|
|
||||||
source: str = Form(default=""),
|
|
||||||
# 比价结果页反馈的「问题场景」(找错商品/优惠不对…);普通反馈不带 → 空串 → 存 NULL。
|
|
||||||
scene: str = Form(default=""),
|
|
||||||
# 提交端环境快照(admin 反馈页展示「提交版本号」「机型OS版本」);旧端不带 → 空串 → 存 NULL。
|
|
||||||
app_version: str = Form(default=""),
|
|
||||||
device_model: str = Form(default=""),
|
|
||||||
rom_name: str = Form(default=""),
|
|
||||||
android_version: str = Form(default=""),
|
|
||||||
images: list[UploadFile] = File(default=[]),
|
images: list[UploadFile] = File(default=[]),
|
||||||
) -> FeedbackOut:
|
) -> FeedbackOut:
|
||||||
content = content.strip()
|
content = content.strip()
|
||||||
@@ -94,8 +71,6 @@ async def submit_feedback(
|
|||||||
raise HTTPException(status_code=400, detail="反馈内容过长")
|
raise HTTPException(status_code=400, detail="反馈内容过长")
|
||||||
if len(contact) > _CONTACT_MAX:
|
if len(contact) > _CONTACT_MAX:
|
||||||
raise HTTPException(status_code=400, detail="联系方式过长")
|
raise HTTPException(status_code=400, detail="联系方式过长")
|
||||||
scene = scene.strip()[:_SCENE_MAX]
|
|
||||||
resolved_source = _resolve_source(source, scene)
|
|
||||||
|
|
||||||
files = [f for f in (images or []) if f is not None and f.filename]
|
files = [f for f in (images or []) if f is not None and f.filename]
|
||||||
if len(files) > _MAX_IMAGES:
|
if len(files) > _MAX_IMAGES:
|
||||||
@@ -111,13 +86,8 @@ async def submit_feedback(
|
|||||||
|
|
||||||
fb = feedback_repo.create_feedback(
|
fb = feedback_repo.create_feedback(
|
||||||
db, user_id=user.id, content=content, contact=contact, images=urls,
|
db, user_id=user.id, content=content, contact=contact, images=urls,
|
||||||
source=resolved_source, scene=scene or None,
|
|
||||||
app_version=app_version.strip(), device_model=device_model.strip(),
|
|
||||||
rom_name=rom_name.strip(), android_version=android_version.strip(),
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"feedback id=%d user_id=%d source=%s images=%d", fb.id, user.id, resolved_source, len(urls)
|
|
||||||
)
|
)
|
||||||
|
logger.info("feedback id=%d user_id=%d images=%d", fb.id, user.id, len(urls))
|
||||||
return FeedbackOut.model_validate(fb)
|
return FeedbackOut.model_validate(fb)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -202,9 +202,6 @@ def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> Withdraw
|
|||||||
order = crud_wallet.create_withdraw(
|
order = crud_wallet.create_withdraw(
|
||||||
db, user.id, req.amount_cents, source=req.source,
|
db, user.id, req.amount_cents, source=req.source,
|
||||||
user_name=req.user_name, out_bill_no=req.out_bill_no,
|
user_name=req.user_name, out_bill_no=req.out_bill_no,
|
||||||
# 0.01 元调试提现:放行低于最低额的小额。双闸——客户端仅 debug 包在「0.01 元提现」开关开时
|
|
||||||
# 连同 skip_review 一起下发;服务端仅非 prod 才认。生产恒 False,最低额校验照常。
|
|
||||||
allow_sub_min=(req.skip_review and not settings.is_prod),
|
|
||||||
)
|
)
|
||||||
except crud_wallet.InvalidWithdrawAmountError as e:
|
except crud_wallet.InvalidWithdrawAmountError as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
+1
-51
@@ -68,7 +68,7 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# 无障碍保护存活监控后台任务(pull 后置检测;本期不接推送)
|
# 无障碍保护存活监控后台任务(pull 后置检测;本期不接推送)
|
||||||
HEARTBEAT_MONITOR_ENABLED: bool = True # 总开关
|
HEARTBEAT_MONITOR_ENABLED: bool = True # 总开关
|
||||||
HEARTBEAT_TIMEOUT_MINUTES: int = 60 # 多久没心跳算掉线(1 小时,避免短暂离线误判被杀)
|
HEARTBEAT_TIMEOUT_MINUTES: int = 10 # 多久没心跳算掉线(≈3 个客户端心跳周期)
|
||||||
HEARTBEAT_SCAN_INTERVAL_SEC: int = 60 # 扫描周期
|
HEARTBEAT_SCAN_INTERVAL_SEC: int = 60 # 扫描周期
|
||||||
|
|
||||||
# ===== 短信 =====
|
# ===== 短信 =====
|
||||||
@@ -113,21 +113,6 @@ class Settings(BaseSettings):
|
|||||||
"""美团 CPS 凭证齐全(缺则接口返空,而非 502)。"""
|
"""美团 CPS 凭证齐全(缺则接口返空,而非 502)。"""
|
||||||
return bool(self.MT_CPS_APP_KEY and self.MT_CPS_APP_SECRET)
|
return bool(self.MT_CPS_APP_KEY and self.MT_CPS_APP_SECRET)
|
||||||
|
|
||||||
# ===== 京东联盟 CPS =====
|
|
||||||
# app_key/app_secret 来自京东联盟应用;site_id 是推广管理里的 APP/网站 ID;
|
|
||||||
# auth_key 是工具商授权 key,自有应用查询可留空。
|
|
||||||
JD_UNION_APP_KEY: str = ""
|
|
||||||
JD_UNION_APP_SECRET: str = ""
|
|
||||||
JD_UNION_SITE_ID: str = ""
|
|
||||||
JD_UNION_AUTH_KEY: str = ""
|
|
||||||
JD_UNION_GATEWAY: str = "https://api.jd.com/routerjson"
|
|
||||||
JD_UNION_TIMEOUT_SEC: int = 15
|
|
||||||
|
|
||||||
@property
|
|
||||||
def jd_union_configured(self) -> bool:
|
|
||||||
"""京东联盟订单查询凭证齐全。"""
|
|
||||||
return bool(self.JD_UNION_APP_KEY and self.JD_UNION_APP_SECRET)
|
|
||||||
|
|
||||||
# ===== 微信服务号(网页授权) =====
|
# ===== 微信服务号(网页授权) =====
|
||||||
# CPS 落地页在微信内拿用户 openid(base 静默)/昵称头像(userinfo),做用户级群统计。
|
# CPS 落地页在微信内拿用户 openid(base 静默)/昵称头像(userinfo),做用户级群统计。
|
||||||
# ⚠️ 区别于 WECHAT_APP_ID(那是 App 移动应用,用于微信支付);这是【已认证服务号】。
|
# ⚠️ 区别于 WECHAT_APP_ID(那是 App 移动应用,用于微信支付);这是【已认证服务号】。
|
||||||
@@ -226,41 +211,6 @@ class Settings(BaseSettings):
|
|||||||
"""回调开关打开且至少配了一个验签密钥,才接受发奖回调。"""
|
"""回调开关打开且至少配了一个验签密钥,才接受发奖回调。"""
|
||||||
return bool(self.PANGLE_CALLBACK_ENABLED and self.pangle_reward_secrets)
|
return bool(self.PANGLE_CALLBACK_ENABLED and self.pangle_reward_secrets)
|
||||||
|
|
||||||
# ===== 穿山甲 GroMore 数据 API(报表收益拉取,T+1)=====
|
|
||||||
# ⚠️ 与上面发奖回调的 m-key 是【两套完全不同的凭证】:这三样在穿山甲后台
|
|
||||||
# 「接入中心 → GroMore-API → 聚合数据报告 API」文档页领取(user_id / role_id / Security Key),
|
|
||||||
# 仅用于按天拉 GroMore 收益报表(revenue 预估收益 + api_revenue 收益Api),不参与发奖。
|
|
||||||
# 该 API 只能查【GroMore 聚合代码位】的数据(=我们 useMediation 的口径),非穿山甲 SDK 数据;
|
|
||||||
# 且不提供用户/设备维度(官方明确),故收益只能落到 日期×代码位 汇总,不能挂到逐条事件。
|
|
||||||
# 子账号(role_id≠user_id)需主账号在「角色管理」授予「查看全部数据」权限,否则查不到
|
|
||||||
# ecpm/revenue(接口返回 118);role_id 填成与 user_id 一致 = 查主账号数据。
|
|
||||||
PANGLE_REPORT_USER_ID: int = 0 # 媒体账号 user_id
|
|
||||||
PANGLE_REPORT_ROLE_ID: int = 0 # 子账号 role_id(=user_id 时查主账号)
|
|
||||||
PANGLE_REPORT_SECURITY_KEY: str = "" # 该账号的 Security Key(secure_key,≠ 发奖 m-key)
|
|
||||||
# GroMore 聚合 AppId(报表 site_id 维度)→ 我们的应用环境。报表按 site_id 区分两个穿山甲应用,
|
|
||||||
# 用它把每行归到 prod(傻瓜比价正式)/ test(测试应用)。默认值取自现网两个应用,部署时按需覆盖。
|
|
||||||
PANGLE_REPORT_SITE_ID_PROD: str = "5830519" # 傻瓜比价正式应用 AppId
|
|
||||||
PANGLE_REPORT_SITE_ID_TEST: str = "5832303" # 测试应用 AppId
|
|
||||||
|
|
||||||
@property
|
|
||||||
def pangle_report_configured(self) -> bool:
|
|
||||||
"""三样齐全(user_id / role_id / security_key)才能拉 GroMore 报表;缺任一 → 同步脚本 no-op。"""
|
|
||||||
return bool(
|
|
||||||
self.PANGLE_REPORT_USER_ID
|
|
||||||
and self.PANGLE_REPORT_ROLE_ID
|
|
||||||
and self.PANGLE_REPORT_SECURITY_KEY
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def pangle_report_site_id_to_env(self) -> dict[str, str]:
|
|
||||||
"""site_id(穿山甲 AppId)→ app_env(prod/test);供同步脚本把报表行归到我们的应用。留空项忽略。"""
|
|
||||||
out: dict[str, str] = {}
|
|
||||||
if self.PANGLE_REPORT_SITE_ID_PROD.strip():
|
|
||||||
out[self.PANGLE_REPORT_SITE_ID_PROD.strip()] = "prod"
|
|
||||||
if self.PANGLE_REPORT_SITE_ID_TEST.strip():
|
|
||||||
out[self.PANGLE_REPORT_SITE_ID_TEST.strip()] = "test"
|
|
||||||
return out
|
|
||||||
|
|
||||||
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
|
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
|
||||||
# pricebot-backend 默认跑在 8000。/api/v1/coupon/step 会透传到这里的 /api/coupon/step
|
# pricebot-backend 默认跑在 8000。/api/v1/coupon/step 会透传到这里的 /api/coupon/step
|
||||||
PRICEBOT_BASE_URL: str = "http://localhost:8000"
|
PRICEBOT_BASE_URL: str = "http://localhost:8000"
|
||||||
|
|||||||
@@ -1,163 +0,0 @@
|
|||||||
"""京东联盟 OpenAPI 客户端。
|
|
||||||
|
|
||||||
当前只接数据大盘需要的订单明细接口:
|
|
||||||
`jd.union.open.order.row.query`。京东要求订单查询时间窗最长 1 小时,
|
|
||||||
调用方负责切窗分页。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_BEIJING = timezone(timedelta(hours=8))
|
|
||||||
|
|
||||||
|
|
||||||
class JdUnionError(RuntimeError):
|
|
||||||
"""京东联盟 API 调用失败。"""
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_json_maybe(value: Any) -> Any:
|
|
||||||
if not isinstance(value, str):
|
|
||||||
return value
|
|
||||||
text = value.strip()
|
|
||||||
if not text:
|
|
||||||
return value
|
|
||||||
try:
|
|
||||||
return json.loads(text)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _sign(params: dict[str, Any], secret: str) -> str:
|
|
||||||
pieces = [secret]
|
|
||||||
for key in sorted(k for k in params if k != "sign"):
|
|
||||||
value = params[key]
|
|
||||||
if value is None:
|
|
||||||
continue
|
|
||||||
pieces.append(f"{key}{value}")
|
|
||||||
pieces.append(secret)
|
|
||||||
raw = "".join(pieces)
|
|
||||||
return hashlib.md5(raw.encode("utf-8")).hexdigest().upper()
|
|
||||||
|
|
||||||
|
|
||||||
def _unwrap_response(data: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
if "error_response" in data:
|
|
||||||
err = data["error_response"] or {}
|
|
||||||
msg = err.get("zh_desc") or err.get("en_desc") or err.get("msg") or err
|
|
||||||
raise JdUnionError(f"京东 API 错误: {msg}")
|
|
||||||
|
|
||||||
body: Any = data
|
|
||||||
for key, value in data.items():
|
|
||||||
if key.endswith("_responce") or key.endswith("_response"):
|
|
||||||
body = value
|
|
||||||
break
|
|
||||||
|
|
||||||
body = _parse_json_maybe(body)
|
|
||||||
if not isinstance(body, dict):
|
|
||||||
raise JdUnionError("京东 API 返回格式异常")
|
|
||||||
|
|
||||||
result = body.get("queryResult", body.get("result", body))
|
|
||||||
result = _parse_json_maybe(result)
|
|
||||||
if not isinstance(result, dict):
|
|
||||||
raise JdUnionError("京东 API 业务结果格式异常")
|
|
||||||
|
|
||||||
code = str(result.get("code", result.get("resultCode", "200")))
|
|
||||||
if code not in {"0", "200"}:
|
|
||||||
msg = result.get("message") or result.get("msg") or result.get("resultMsg") or result
|
|
||||||
raise JdUnionError(f"京东 API 业务错误: {msg}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def call(method: str, payload: dict[str, Any], *, version: str = "1.0") -> dict[str, Any]:
|
|
||||||
if not settings.jd_union_configured:
|
|
||||||
raise JdUnionError("京东联盟凭证未配置")
|
|
||||||
|
|
||||||
biz_json = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|
||||||
params: dict[str, Any] = {
|
|
||||||
"method": method,
|
|
||||||
"app_key": settings.JD_UNION_APP_KEY,
|
|
||||||
"timestamp": datetime.now(_BEIJING).strftime("%Y-%m-%d %H:%M:%S"),
|
|
||||||
"format": "json",
|
|
||||||
"v": version,
|
|
||||||
"sign_method": "md5",
|
|
||||||
"360buy_param_json": biz_json,
|
|
||||||
}
|
|
||||||
params["sign"] = _sign(params, settings.JD_UNION_APP_SECRET)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with httpx.Client(timeout=settings.JD_UNION_TIMEOUT_SEC, trust_env=False) as client:
|
|
||||||
resp = client.post(settings.JD_UNION_GATEWAY, data=params)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
except httpx.HTTPError as e:
|
|
||||||
raise JdUnionError(f"京东 API 网络错误: {e}") from e
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
raise JdUnionError("京东 API 返回非 JSON") from e
|
|
||||||
|
|
||||||
return _unwrap_response(data)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_rows(result: dict[str, Any]) -> tuple[list[dict[str, Any]], bool]:
|
|
||||||
payload = _parse_json_maybe(result.get("data", result.get("result", result)))
|
|
||||||
has_more = bool(result.get("hasMore") or result.get("has_more"))
|
|
||||||
|
|
||||||
if isinstance(payload, dict):
|
|
||||||
for key in ("orderRowResp", "orderRows", "orderList", "orders", "list", "rows"):
|
|
||||||
rows = _parse_json_maybe(payload.get(key))
|
|
||||||
if isinstance(rows, list):
|
|
||||||
return [r for r in rows if isinstance(r, dict)], bool(
|
|
||||||
payload.get("hasMore") or payload.get("has_more") or has_more
|
|
||||||
)
|
|
||||||
return [], bool(payload.get("hasMore") or payload.get("has_more") or has_more)
|
|
||||||
if isinstance(payload, list):
|
|
||||||
return [r for r in payload if isinstance(r, dict)], has_more
|
|
||||||
return [], has_more
|
|
||||||
|
|
||||||
|
|
||||||
def query_order_rows(
|
|
||||||
*,
|
|
||||||
start_time: datetime,
|
|
||||||
end_time: datetime,
|
|
||||||
query_time_type: int = 3,
|
|
||||||
page_index: int = 1,
|
|
||||||
page_size: int = 200,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""查询京东 CPS 订单行。
|
|
||||||
|
|
||||||
query_time_type: 1 下单时间, 2 完成时间, 3 更新时间。
|
|
||||||
start_time/end_time 用北京时间展示给京东;调用方需保证窗口不超过 1 小时。
|
|
||||||
"""
|
|
||||||
start_bj = start_time.astimezone(_BEIJING)
|
|
||||||
end_bj = end_time.astimezone(_BEIJING)
|
|
||||||
if end_bj <= start_bj:
|
|
||||||
return {"rows": [], "has_more": False}
|
|
||||||
if end_bj - start_bj > timedelta(hours=1):
|
|
||||||
raise JdUnionError("京东订单查询单次时间窗不能超过 1 小时")
|
|
||||||
|
|
||||||
order_req: dict[str, Any] = {
|
|
||||||
"pageIndex": page_index,
|
|
||||||
"pageSize": min(max(page_size, 1), 200),
|
|
||||||
"type": query_time_type,
|
|
||||||
"startTime": start_bj.strftime("%Y-%m-%d %H:%M:%S"),
|
|
||||||
"endTime": end_bj.strftime("%Y-%m-%d %H:%M:%S"),
|
|
||||||
}
|
|
||||||
if settings.JD_UNION_AUTH_KEY:
|
|
||||||
order_req["key"] = settings.JD_UNION_AUTH_KEY
|
|
||||||
result = call("jd.union.open.order.row.query", {"orderReq": order_req})
|
|
||||||
rows, has_more = _extract_rows(result)
|
|
||||||
logger.info(
|
|
||||||
"jd.union.open.order.row.query fetched rows=%s page=%s has_more=%s",
|
|
||||||
len(rows),
|
|
||||||
page_index,
|
|
||||||
has_more,
|
|
||||||
)
|
|
||||||
return {"rows": rows, "has_more": has_more, "raw": result}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
"""穿山甲 GroMore「聚合数据报告 API」客户端 —— 按天拉取收益报表(只读、T+1)。
|
|
||||||
|
|
||||||
⚠️ 这是 **GroMore 数据拉取 API**,与发奖回调验签(integrations/pangle.py 的 m-key)是
|
|
||||||
两套完全不同的凭证与用途。凭证在后台「接入中心 → GroMore-API → 聚合数据报告 API」领取:
|
|
||||||
媒体账号 user_id、子账号 role_id、Security Key(secure_key)。
|
|
||||||
|
|
||||||
鉴权(文档 v2.x「方法一」):
|
|
||||||
1. 去掉请求参数里的 `sign` 字段与值为空的字段;
|
|
||||||
2. 其余参数按 key 字典序升序,拼成 `k1=v1&k2=v2&...&kn=vn`;
|
|
||||||
3. 末尾直接拼接 security_key(无分隔符),对整串做 MD5,取 32 位小写十六进制 = sign。
|
|
||||||
签名有 3 分钟过期(天级用 `timestamp` 秒级时间戳;小时级才用 `current_time` 字符串)。
|
|
||||||
|
|
||||||
数据口径要点(来自官方文档):
|
|
||||||
- 只返回【GroMore 聚合代码位】在 GroMore 内的数据(=我们 useMediation 的口径),
|
|
||||||
查不到穿山甲 SDK 自身的数据;
|
|
||||||
- **不提供分用户/设备维度**(官方 FAQ 明确拒绝),最细到 日期×应用×代码位×广告源;
|
|
||||||
- `revenue` = 预估收益(元,所有 ADN 都有);`api_revenue` = 收益Api(各 ADN 经 Reporting
|
|
||||||
回传、按实时汇率折算账号币种,更接近结算),需后台为该 ADN 配置 Reporting 才有、且不支持当天;
|
|
||||||
- 「今天」与「今天以前」必须分开查;天级跨度 ≤ 1 个月、不早于 12 个月。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from collections.abc import Mapping
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
logger = logging.getLogger("shagua.pangle_report")
|
|
||||||
|
|
||||||
HOST = "https://www.csjplatform.com"
|
|
||||||
# 天级收益报表(路径与文档代码示例一致;另有小时级 get_hour_report_data,本服务只用天级)。
|
|
||||||
DAILY_PATH = "/union_media/open/api/mediation/get_daily_income_report_data"
|
|
||||||
VERSION = "2.0"
|
|
||||||
SIGN_TYPE = "MD5"
|
|
||||||
PAGE_LIMIT = 5000 # 文档上限;一次尽量多取,减少翻页
|
|
||||||
DEFAULT_TIMEOUT = 30.0
|
|
||||||
_MAX_PAGES = 200 # 翻页安全阀(5000×200=100w 行,远超我们规模),防异常 has_next 死循环
|
|
||||||
|
|
||||||
|
|
||||||
class PangleReportError(Exception):
|
|
||||||
"""GroMore 报表接口调用失败(未配置 / 网络 / 业务码非 100)。"""
|
|
||||||
|
|
||||||
|
|
||||||
def build_sign(params: Mapping[str, Any], security_key: str) -> str:
|
|
||||||
"""按文档「方法一」生成 sign:去 sign/空值 → key 升序 → k=v& 拼接 → 末尾接 secure_key → MD5(32 位小写)。
|
|
||||||
|
|
||||||
与官方 Python/Java 示例逐字节一致(见 tests/test_pangle_report.py 的两个测试向量)。
|
|
||||||
"""
|
|
||||||
items = [
|
|
||||||
(str(k), str(v))
|
|
||||||
for k, v in params.items()
|
|
||||||
if k != "sign" and v is not None and str(v) != ""
|
|
||||||
]
|
|
||||||
items.sort(key=lambda kv: kv[0])
|
|
||||||
raw = "&".join(f"{k}={v}" for k, v in items)
|
|
||||||
return hashlib.md5((raw + security_key).encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_daily_report(
|
|
||||||
*,
|
|
||||||
start_date: str,
|
|
||||||
end_date: str,
|
|
||||||
# ⚠️ 用 ad_unit_id(GroMore 广告位ID = 我们的 104xxx),不要用 code_id(底层各 ADN 代码位,对不上)
|
|
||||||
dimensions: str = "date,site_id,ad_unit_id",
|
|
||||||
metrics: str = "revenue,api_revenue,ecpm,imp_cnt",
|
|
||||||
site_ids: str | None = None,
|
|
||||||
code_ids: str | None = None,
|
|
||||||
os: str | None = None,
|
|
||||||
network: str | None = None,
|
|
||||||
time_zone: int = 8,
|
|
||||||
limit: int = PAGE_LIMIT,
|
|
||||||
timeout: float = DEFAULT_TIMEOUT,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""拉一个日期区间(北京时间,time_zone=8)的天级收益报表,自动翻页,返回 report_list 全量行。
|
|
||||||
|
|
||||||
每行是字符串字典(接口原值),形如
|
|
||||||
{"start_date": "2026-06-27", "site_id": "5830519", "ad_unit_id": "104142227",
|
|
||||||
"revenue": "1.23", "api_revenue": "1.05", "ecpm": "0.80", "imp_cnt": "1537", ...}。
|
|
||||||
业务码非 100 直接抛 PangleReportError(由调用方/脚本兜底,不静默吞)。
|
|
||||||
"""
|
|
||||||
if not settings.pangle_report_configured:
|
|
||||||
raise PangleReportError(
|
|
||||||
"PANGLE_REPORT_USER_ID / ROLE_ID / SECURITY_KEY 未配置,无法拉取 GroMore 报表"
|
|
||||||
)
|
|
||||||
|
|
||||||
base_params: dict[str, Any] = {
|
|
||||||
"user_id": settings.PANGLE_REPORT_USER_ID,
|
|
||||||
"role_id": settings.PANGLE_REPORT_ROLE_ID,
|
|
||||||
"version": VERSION,
|
|
||||||
"sign_type": SIGN_TYPE,
|
|
||||||
"start_date": start_date,
|
|
||||||
"end_date": end_date,
|
|
||||||
"dimensions": dimensions,
|
|
||||||
"metrics": metrics,
|
|
||||||
"time_zone": time_zone,
|
|
||||||
"limit": limit,
|
|
||||||
}
|
|
||||||
# 可选过滤(空则不传,避免进签名串)
|
|
||||||
for key, val in (
|
|
||||||
("site_ids", site_ids), ("code_ids", code_ids),
|
|
||||||
("os", os), ("network", network),
|
|
||||||
):
|
|
||||||
if val:
|
|
||||||
base_params[key] = val
|
|
||||||
|
|
||||||
rows: list[dict[str, Any]] = []
|
|
||||||
offset = 0
|
|
||||||
try:
|
|
||||||
with httpx.Client(timeout=timeout) as client:
|
|
||||||
for _ in range(_MAX_PAGES):
|
|
||||||
params = dict(base_params)
|
|
||||||
params["offset"] = offset
|
|
||||||
# timestamp 每页临请求时取最新(3 分钟过期),并参与签名
|
|
||||||
params["timestamp"] = int(time.time())
|
|
||||||
params["sign"] = build_sign(params, settings.PANGLE_REPORT_SECURITY_KEY)
|
|
||||||
|
|
||||||
resp = client.get(HOST + DAILY_PATH, params=params)
|
|
||||||
resp.raise_for_status()
|
|
||||||
body = resp.json()
|
|
||||||
|
|
||||||
code = str(body.get("code"))
|
|
||||||
if code != "100":
|
|
||||||
raise PangleReportError(
|
|
||||||
f"GroMore 报表业务失败 code={code} message={body.get('message')!r} "
|
|
||||||
f"(101=验签失败/102=userid无效/107=无权限/118=无收益查看权限,详见文档状态码)"
|
|
||||||
)
|
|
||||||
|
|
||||||
data = body.get("data") or {}
|
|
||||||
page = data.get("report_list") or []
|
|
||||||
rows.extend(page)
|
|
||||||
|
|
||||||
# has_next=1 还有下一页;无该字段则按本页是否取满判断
|
|
||||||
has_next = str(data.get("has_next", "")) == "1"
|
|
||||||
if not page or not has_next:
|
|
||||||
break
|
|
||||||
offset += len(page)
|
|
||||||
except httpx.HTTPError as e:
|
|
||||||
raise PangleReportError(f"GroMore 报表请求异常: {e}") from e
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"GroMore 天级报表拉取完成 %s~%s dims=%s 行数=%d", start_date, end_date, dimensions, len(rows)
|
|
||||||
)
|
|
||||||
return rows
|
|
||||||
+10
-35
@@ -2,23 +2,18 @@
|
|||||||
|
|
||||||
通过 `uvicorn app.main:app --reload` 启动。
|
通过 `uvicorn app.main:app --reload` 启动。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from app.api.internal.app_version import router as internal_app_version_router
|
|
||||||
from app.api.internal.launch_confirm import router as internal_launch_confirm_router
|
|
||||||
from app.api.internal.price import router as internal_price_router
|
|
||||||
from app.api.internal.store import router as internal_store_router
|
|
||||||
from app.api.v1.ad import router as ad_router
|
from app.api.v1.ad import router as ad_router
|
||||||
from app.api.v1.analytics import router as analytics_router
|
from app.api.v1.analytics import router as analytics_router
|
||||||
from app.api.v1.auth import router as auth_router
|
from app.api.v1.auth import router as auth_router
|
||||||
@@ -26,8 +21,12 @@ from app.api.v1.compare import router as compare_router
|
|||||||
from app.api.v1.compare_milestone import router as compare_milestone_router
|
from app.api.v1.compare_milestone import router as compare_milestone_router
|
||||||
from app.api.v1.compare_record import router as compare_record_router
|
from app.api.v1.compare_record import router as compare_record_router
|
||||||
from app.api.v1.coupon import router as coupon_router
|
from app.api.v1.coupon import router as coupon_router
|
||||||
from app.api.v1.cps_redirect import router as cps_redirect_router
|
|
||||||
from app.api.v1.device import router as device_router
|
from app.api.v1.device import router as device_router
|
||||||
|
from app.api.v1.cps_redirect import router as cps_redirect_router
|
||||||
|
from app.api.internal.app_version import router as internal_app_version_router
|
||||||
|
from app.api.internal.launch_confirm import router as internal_launch_confirm_router
|
||||||
|
from app.api.internal.price import router as internal_price_router
|
||||||
|
from app.api.internal.store import router as internal_store_router
|
||||||
from app.api.v1.feedback import router as feedback_router
|
from app.api.v1.feedback import router as feedback_router
|
||||||
from app.api.v1.invite import router as invite_router
|
from app.api.v1.invite import router as invite_router
|
||||||
from app.api.v1.meituan import router as meituan_router
|
from app.api.v1.meituan import router as meituan_router
|
||||||
@@ -41,14 +40,14 @@ from app.api.v1.user import router as user_router
|
|||||||
from app.api.v1.wallet import router as wallet_router
|
from app.api.v1.wallet import router as wallet_router
|
||||||
from app.api.v1.wxpay import router as wxpay_router
|
from app.api.v1.wxpay import router as wxpay_router
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.daily_exchange_worker import (
|
|
||||||
start_daily_exchange_worker,
|
|
||||||
stop_daily_exchange_worker,
|
|
||||||
)
|
|
||||||
from app.core.heartbeat_monitor_worker import (
|
from app.core.heartbeat_monitor_worker import (
|
||||||
start_heartbeat_monitor,
|
start_heartbeat_monitor,
|
||||||
stop_heartbeat_monitor,
|
stop_heartbeat_monitor,
|
||||||
)
|
)
|
||||||
|
from app.core.daily_exchange_worker import (
|
||||||
|
start_daily_exchange_worker,
|
||||||
|
stop_daily_exchange_worker,
|
||||||
|
)
|
||||||
from app.core.logging import setup_logging
|
from app.core.logging import setup_logging
|
||||||
from app.core.pricebot_client import aclose_pricebot_client, get_pricebot_client
|
from app.core.pricebot_client import aclose_pricebot_client, get_pricebot_client
|
||||||
from app.core.withdraw_reconcile_worker import (
|
from app.core.withdraw_reconcile_worker import (
|
||||||
@@ -138,30 +137,6 @@ app.include_router(cps_redirect_router)
|
|||||||
# 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。
|
# 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。
|
||||||
_media_root = Path(settings.MEDIA_ROOT)
|
_media_root = Path(settings.MEDIA_ROOT)
|
||||||
_media_root.mkdir(parents=True, exist_ok=True)
|
_media_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
# 官网下载的 APK 直链(落地页 dl.html「官网下载」按钮指向 /media/shaguabijia.apk)。
|
|
||||||
# 必须在 StaticFiles 挂载【之前】注册,否则被静态挂载吃掉。
|
|
||||||
# StaticFiles 给 .apk 的 Content-Type 不对、且无 attachment 头 → 部分国产浏览器不触发下载、转甩应用市场;
|
|
||||||
# 这里显式回 application/vnd.android.package-archive + Content-Disposition:attachment 强制浏览器下载。
|
|
||||||
# 文件由 scripts/publish_apk.sh 编 release 包后放到 data/media/shaguabijia.apk(*.apk 不入 git,需部署时放)。
|
|
||||||
_APK_PATH = _media_root / "shaguabijia.apk"
|
|
||||||
|
|
||||||
|
|
||||||
@app.get(f"{settings.MEDIA_URL_PREFIX}/shaguabijia.apk", tags=["meta"], include_in_schema=False)
|
|
||||||
def download_apk() -> FileResponse:
|
|
||||||
if not _APK_PATH.is_file():
|
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
raise HTTPException(status_code=404, detail="安装包未就绪")
|
|
||||||
return FileResponse(
|
|
||||||
_APK_PATH,
|
|
||||||
media_type="application/vnd.android.package-archive",
|
|
||||||
filename="shaguabijia.apk",
|
|
||||||
headers={"Content-Disposition": 'attachment; filename="shaguabijia.apk"'},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
app.mount(
|
app.mount(
|
||||||
settings.MEDIA_URL_PREFIX,
|
settings.MEDIA_URL_PREFIX,
|
||||||
StaticFiles(directory=str(_media_root)),
|
StaticFiles(directory=str(_media_root)),
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""所有 ORM model 必须在这里 import 一次,Alembic / metadata 才能扫到。"""
|
"""所有 ORM model 必须在这里 import 一次,Alembic / metadata 才能扫到。"""
|
||||||
from app.models.ad_ecpm import AdEcpmRecord # noqa: F401
|
from app.models.ad_ecpm import AdEcpmRecord # noqa: F401
|
||||||
from app.models.ad_feed_reward import AdFeedRewardRecord # noqa: F401
|
from app.models.ad_feed_reward import AdFeedRewardRecord # noqa: F401
|
||||||
from app.models.ad_pangle_revenue import AdPangleDailyRevenue # noqa: F401
|
|
||||||
from app.models.ad_reward import AdRewardRecord # noqa: F401
|
from app.models.ad_reward import AdRewardRecord # noqa: F401
|
||||||
from app.models.ad_watch_log import AdWatchLog # 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 import AdminAuditLog, AdminUser # noqa: F401
|
||||||
@@ -19,8 +18,8 @@ from app.models.coupon_state import ( # noqa: F401
|
|||||||
CouponClaimRecord,
|
CouponClaimRecord,
|
||||||
CouponDailyCompletion,
|
CouponDailyCompletion,
|
||||||
CouponPromptEngagement,
|
CouponPromptEngagement,
|
||||||
CouponSession,
|
|
||||||
)
|
)
|
||||||
|
from app.models.cps_order import CpsOrder # noqa: F401
|
||||||
from app.models.feedback import Feedback # noqa: F401
|
from app.models.feedback import Feedback # noqa: F401
|
||||||
from app.models.invite import InviteRelation # noqa: F401
|
from app.models.invite import InviteRelation # noqa: F401
|
||||||
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
|
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
"""穿山甲 GroMore 天级收益报表(后台结算口径,定时拉取入库)。
|
|
||||||
|
|
||||||
每行 = GroMore 数据 API 返回的一条「日期 × 应用 × 代码位」聚合收益(`integrations/pangle_report`
|
|
||||||
+ `scripts/sync_pangle_revenue` 落库)。**权威/预估收益的来源**,与 `ad_ecpm_record`(客户端自报
|
|
||||||
eCPM 折算的预估)互为对照:
|
|
||||||
|
|
||||||
- `revenue_yuan` ← 接口 `revenue`(预估收益,元;排序价×展示/1000,所有 ADN 都有);
|
|
||||||
- `api_revenue_yuan` ← 接口 `api_revenue`(收益Api,元;各 ADN 经 Reporting 回传、更接近结算;
|
|
||||||
未配置该 ADN 的 Reporting 或查当天时为空)。
|
|
||||||
|
|
||||||
⚠️ 穿山甲不提供分用户/设备维度,故本表最细只到 日期×应用×代码位,**无法挂到逐条广告事件**;
|
|
||||||
广告收益报表里只用于汇总/趋势级的「穿山甲后台收益」,不改逐条行的客户端预估。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import (
|
|
||||||
DateTime,
|
|
||||||
Float,
|
|
||||||
Integer,
|
|
||||||
String,
|
|
||||||
UniqueConstraint,
|
|
||||||
func,
|
|
||||||
)
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.db.base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class AdPangleDailyRevenue(Base):
|
|
||||||
__tablename__ = "ad_pangle_daily_revenue"
|
|
||||||
__table_args__ = (
|
|
||||||
# 一行 = (日期, 应用, 代码位, 广告源);adn 用 "" 表示「未分广告源、该代码位汇总」,
|
|
||||||
# 避免 NULL 在唯一约束里被视为各不相同导致 upsert 重复(SQLite/PG 行为一致)。
|
|
||||||
UniqueConstraint(
|
|
||||||
"report_date", "app_env", "our_code_id", "adn",
|
|
||||||
name="uq_ad_pangle_daily",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
# 北京时间日期串 'YYYY-MM-DD'(拉取时 time_zone=8),与 ad_ecpm_record.report_date 同口径,可直接 join。
|
|
||||||
report_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False)
|
|
||||||
# 我们的应用环境:prod(傻瓜比价正式)/ test(测试);由 site_id 经 settings 映射而来。
|
|
||||||
app_env: Mapped[str] = mapped_column(String(16), nullable=False)
|
|
||||||
# 原始 GroMore AppId(site_id),留痕便于排查映射。
|
|
||||||
site_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
|
||||||
# 广告位 ID(= 接口 ad_unit_id = 我们客户端配的 104xxx = ad_ecpm_record.our_code_id),join key。
|
|
||||||
# ⚠️ 不是接口的 code_id —— 那是底层各 ADN 的代码位(如 983674557),对不上我们的口径。
|
|
||||||
our_code_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
|
||||||
# 广告源(接口 network 数字→名,如 pangle/gdt);"" = 未分广告源的代码位汇总行(当前默认口径)。
|
|
||||||
adn: Mapped[str] = mapped_column(String(16), nullable=False, default="")
|
|
||||||
# 预估收益(元)← 接口 revenue。
|
|
||||||
revenue_yuan: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
|
||||||
# 收益Api(元)← 接口 api_revenue;未配 Reporting / 当天 等情况接口不返回 → NULL。
|
|
||||||
api_revenue_yuan: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
||||||
# 预估 eCPM 原值(接口 ecpm,单位元/千次,**与客户端 getEcpm 的「分」不同**),参考用原样存。
|
|
||||||
ecpm: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
|
||||||
# 展示次数 ← 接口 imp_cnt。
|
|
||||||
impressions: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
||||||
# 货币类型(接口 currency,正常为 cny)。
|
|
||||||
currency: Mapped[str] = mapped_column(String(8), nullable=False, default="cny")
|
|
||||||
# 最近一次同步写入时间(同一行可被多次回补覆盖;T+1 数据穿山甲会订正)。
|
|
||||||
synced_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str: # pragma: no cover
|
|
||||||
return (
|
|
||||||
f"<AdPangleDailyRevenue {self.report_date} {self.app_env} "
|
|
||||||
f"code={self.our_code_id} revenue={self.revenue_yuan}>"
|
|
||||||
)
|
|
||||||
@@ -181,79 +181,3 @@ class CouponPromptEngagement(Base):
|
|||||||
f"<CouponPromptEngagement device={self.device_id} "
|
f"<CouponPromptEngagement device={self.device_id} "
|
||||||
f"date={self.engage_date} type={self.engage_type}>"
|
f"date={self.engage_date} type={self.engage_type}>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class CouponSession(Base):
|
|
||||||
"""一次领券任务的全程流水(admin「领券数据」看板数据源,2026-06-30)。
|
|
||||||
|
|
||||||
与 coupon_claim_record(按券一天一条去重)、coupon_daily_completion(按设备一天一条)都不同:
|
|
||||||
本表**一次领券一条**(trace_id 唯一),记从发起(started)到收尾(completed/failed/abandoned)的
|
|
||||||
全程耗时 + 各平台耗时 + 机型/ROM。客户端 POST /api/v1/coupon/session 两段上报:发起建行、
|
|
||||||
收尾按 trace_id 更新同一行。发起即落库 → admin 可算「发起数」与中途流失(started 无终态=未完成)。
|
|
||||||
|
|
||||||
口径:elapsed_ms 由客户端全程计时(点发起→收尾)、权威;started_at/finished_at 为时刻留痕。
|
|
||||||
started_date = started_at 的 Asia/Shanghai 自然日,供 admin 按天聚合 / 日期筛选(索引)。
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "coupon_session"
|
|
||||||
__table_args__ = (
|
|
||||||
# 一次领券一行:trace_id 幂等 upsert(发起建、收尾更新同一行)。
|
|
||||||
UniqueConstraint("trace_id", name="uq_coupon_session_trace"),
|
|
||||||
# admin 主聚合/筛选:按上海自然日 + 环境(报表默认只看 prod,避免测试数据串台)。
|
|
||||||
Index("ix_coupon_session_date_env", "started_date", "app_env"),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
|
|
||||||
# 一次领券唯一 id(客户端 UUID,全程贯穿),upsert 键。
|
|
||||||
trace_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
||||||
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
||||||
# 登录态才带(admin join 用户表出手机号/昵称);匿名领券为空。
|
|
||||||
user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
|
|
||||||
|
|
||||||
# started / completed / failed / abandoned。started 无终态 = 中途流失(未完成)。
|
|
||||||
status: Mapped[str] = mapped_column(String(16), nullable=False)
|
|
||||||
# prod / dev(客户端 BuildConfig.DEBUG)。admin 报表默认只看 prod(对齐广告报表防串台口径)。
|
|
||||||
app_env: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True)
|
|
||||||
|
|
||||||
# 发起勾选平台 ["meituan-waimai", ...](空=全领)。
|
|
||||||
platforms: Mapped[list | None] = mapped_column(_JSON, nullable=True)
|
|
||||||
# 发起来源外卖 App 包名;null=App 内(傻瓜比价首页)发起,非空=从美团/淘宝/京东弹券发起。
|
|
||||||
# admin「发起平台」列据此区分(空→傻瓜比价,包名→对应平台)。
|
|
||||||
origin_package: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
||||||
# 机型(Build.MANUFACTURER + MODEL)与 ROM(OemDetector,如 "ColorOS 14")。明细「机型/ROM」列 + 维度筛选。
|
|
||||||
device_model: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
||||||
rom: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
||||||
|
|
||||||
# 发起时刻(客户端墙钟):明细「时间」列、趋势 X 轴。
|
|
||||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
||||||
# 发起的 Asia/Shanghai 自然日:按天聚合 / 日期范围筛选(索引)。
|
|
||||||
started_date: Mapped[date] = mapped_column(Date, nullable=False)
|
|
||||||
# 收尾时刻(服务端 now);未收尾(流失)为空。
|
|
||||||
finished_at: Mapped[datetime | None] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# 全程耗时(ms,客户端点发起→收尾):平均 / 分位都基于它(只统计 completed)。
|
|
||||||
elapsed_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
||||||
# 各平台领券耗时 {"meituan-waimai":3200,...}(ms)。明细美团/淘宝/京东耗时列。
|
|
||||||
platform_elapsed: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
|
|
||||||
# 领到总张数(收尾帧带)。
|
|
||||||
claimed_count: Mapped[int | None] = mapped_column(Integer, 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)
|
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
|
||||||
)
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str: # pragma: no cover
|
|
||||||
return (
|
|
||||||
f"<CouponSession trace={self.trace_id} status={self.status} "
|
|
||||||
f"elapsed_ms={self.elapsed_ms}>"
|
|
||||||
)
|
|
||||||
|
|||||||
+4
-22
@@ -1,13 +1,11 @@
|
|||||||
"""CPS 对账订单(cps_order)。
|
"""CPS 对账订单(cps_order)。
|
||||||
|
|
||||||
从联盟 API 按时间窗拉回、按平台落库的 CPS 订单明细。字段最初对齐美团 query_order,
|
从美团联盟 query_order 按时间窗拉回、按 sid 归群的订单明细。字段对齐 query_order
|
||||||
后续兼容京东订单报表:
|
|
||||||
实测返回:
|
实测返回:
|
||||||
- payPrice / profit 是「元」字符串 → 入库统一转「分」(与全站口径一致)
|
- payPrice / profit 是「元」字符串 → 入库统一转「分」(与全站口径一致)
|
||||||
- payTime / updateTime 是秒级时间戳 → 入库转 tz-aware datetime
|
- payTime / updateTime 是秒级时间戳 → 入库转 tz-aware datetime
|
||||||
- status: 2付款 3完成 4取消 5风控 6结算(取消/风控不计佣金)
|
- status: 2付款 3完成 4取消 5风控 6结算(取消/风控不计佣金)
|
||||||
order_id 全局唯一,reconcile 按它 upsert(订单状态会变,重复拉则更新)。京东订单用
|
order_id 全局唯一,reconcile 按它 upsert(订单状态会变,重复拉则更新)。
|
||||||
`jd:<row_id>` 前缀避免与美团订单号碰撞。
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -24,13 +22,8 @@ class CpsOrder(Base):
|
|||||||
__tablename__ = "cps_order"
|
__tablename__ = "cps_order"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
# 平台:meituan / jd。历史数据迁移默认 meituan。
|
# 美团订单号(加密串),全局唯一,upsert 幂等键。
|
||||||
platform: Mapped[str] = mapped_column(String(20), default="meituan", index=True, nullable=False)
|
|
||||||
# 平台订单号/行号包装后的全局唯一键,upsert 幂等。
|
|
||||||
order_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
order_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||||
# 平台原始订单号/行号。京东一笔订单多 SKU 时可按行号区分。
|
|
||||||
external_order_id: Mapped[str | None] = mapped_column(String(128), index=True, nullable=True)
|
|
||||||
external_row_id: Mapped[str | None] = mapped_column(String(128), index=True, nullable=True)
|
|
||||||
# 渠道追踪位 = 群 sid(历史无 sid 订单为空)。按它归群聚合。
|
# 渠道追踪位 = 群 sid(历史无 sid 订单为空)。按它归群聚合。
|
||||||
sid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
sid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||||
act_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
act_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||||
@@ -42,21 +35,11 @@ class CpsOrder(Base):
|
|||||||
commission_rate: Mapped[str | None] = mapped_column(String(16), nullable=True) # "300"=3% "10"=0.1%
|
commission_rate: Mapped[str | None] = mapped_column(String(16), nullable=True) # "300"=3% "10"=0.1%
|
||||||
refund_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
refund_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
refund_profit_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
refund_profit_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
# 通用佣金拆分。美团只有预估 profit;京东有预估/实际佣金。
|
|
||||||
estimated_commission_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
||||||
actual_commission_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
||||||
|
|
||||||
# 美团订单状态: 2付款 3完成 4取消 5风控 6结算
|
# 美团订单状态: 2付款 3完成 4取消 5风控 6结算
|
||||||
mt_status: Mapped[str | None] = mapped_column(String(8), index=True, nullable=True)
|
mt_status: Mapped[str | None] = mapped_column(String(8), index=True, nullable=True)
|
||||||
# 京东订单有效码(validCode),用于判断是否有效/已完成。
|
|
||||||
jd_valid_code: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True)
|
|
||||||
invalid_reason: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
invalid_reason: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
product_name: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
product_name: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
settle_month: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
|
||||||
site_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
||||||
position_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
||||||
pid: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
||||||
sub_union_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
||||||
|
|
||||||
pay_time: Mapped[datetime | None] = mapped_column(
|
pay_time: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime(timezone=True), index=True, nullable=True
|
DateTime(timezone=True), index=True, nullable=True
|
||||||
@@ -76,6 +59,5 @@ class CpsOrder(Base):
|
|||||||
def __repr__(self) -> str: # pragma: no cover
|
def __repr__(self) -> str: # pragma: no cover
|
||||||
return (
|
return (
|
||||||
f"<CpsOrder id={self.id} order_id={self.order_id!r} "
|
f"<CpsOrder id={self.id} order_id={self.order_id!r} "
|
||||||
f"platform={self.platform!r} sid={self.sid!r} "
|
f"sid={self.sid!r} status={self.mt_status} profit_cents={self.commission_cents}>"
|
||||||
f"status={self.mt_status or self.jd_valid_code} profit_cents={self.commission_cents}>"
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,30 +23,14 @@ class Feedback(Base):
|
|||||||
)
|
)
|
||||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
contact: Mapped[str] = mapped_column(String(128), nullable=False)
|
contact: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
# 反馈来源入口:profile(「我的」页反馈入口)/ comparison(比价结果页反馈入口)。
|
|
||||||
# admin 据此展示/筛选「反馈类型」。旧数据与「我的」页反馈均为 profile(server_default)。
|
|
||||||
# 客户端显式传 source 优先;未传时由 scene 有无派生(见 api/v1/feedback.submit_feedback)。
|
|
||||||
source: Mapped[str] = mapped_column(
|
|
||||||
String(16), nullable=False, default="profile", server_default="profile", index=True
|
|
||||||
)
|
|
||||||
# 比价反馈的「问题场景」(找错商品/优惠不对/比价太慢…),比价结果页反馈才有;普通反馈为 NULL。
|
|
||||||
scene: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
|
||||||
# 截图 URL 列表(相对路径,如 ["/media/feedback/u1_ab12.jpg"]);无图为 None
|
# 截图 URL 列表(相对路径,如 ["/media/feedback/u1_ab12.jpg"]);无图为 None
|
||||||
images: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
|
images: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
|
||||||
# 提交时的端环境快照(admin 排查用;客户端改版带上后的新反馈才有,历史数据为 NULL)
|
|
||||||
app_version: Mapped[str | None] = mapped_column(String(32), nullable=True) # 我们 app versionName
|
|
||||||
device_model: Mapped[str | None] = mapped_column(String(64), nullable=True) # Build.MODEL
|
|
||||||
rom_name: Mapped[str | None] = mapped_column(String(32), nullable=True) # OemDetector os:ColorOS/MIUI/...
|
|
||||||
android_version: Mapped[str | None] = mapped_column(String(16), nullable=True) # Build.VERSION.RELEASE
|
|
||||||
# pending(审核中) / adopted(已采纳) / rejected(未采纳)
|
# pending(审核中) / adopted(已采纳) / rejected(未采纳)
|
||||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True)
|
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True)
|
||||||
reject_reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
reject_reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||||
reward_coins: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
reward_coins: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
# 审核批注:采纳时可写采纳要点,未采纳时也可保留运营侧备注
|
# 审核批注:采纳时可写采纳要点,未采纳时也可保留运营侧备注
|
||||||
review_note: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
review_note: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||||
# 运营给用户的回复留言(**用户端可见**,采纳/未采纳都可填,随「我的反馈」历史下发)。
|
|
||||||
# 与 review_note(内部备注,用户不可见)、reject_reason(未采纳原因)区分开。
|
|
||||||
admin_reply: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
|
||||||
reviewed_by_admin_id: Mapped[int | None] = mapped_column(
|
reviewed_by_admin_id: Mapped[int | None] = mapped_column(
|
||||||
Integer, ForeignKey("admin_user.id"), nullable=True
|
Integer, ForeignKey("admin_user.id"), nullable=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
"""穿山甲 GroMore 天级收益 读写(`ad_pangle_daily_revenue` 表)。
|
|
||||||
|
|
||||||
`scripts/sync_pangle_revenue` 拉数后调 `upsert_daily_rows` 落库(同一(日期×应用×代码位×广告源)
|
|
||||||
幂等覆盖,T+1 订正可重跑);admin 广告收益报表调 `aggregate_by_date` 取「穿山甲后台收益」做
|
|
||||||
汇总/趋势级展示。穿山甲无用户维度,故这里不涉及 user_id。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, TypedDict
|
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.models.ad_pangle_revenue import AdPangleDailyRevenue
|
|
||||||
|
|
||||||
# upsert 时可被覆盖更新的列(唯一键之外的业务列)。
|
|
||||||
_UPDATABLE = ("site_id", "revenue_yuan", "api_revenue_yuan", "ecpm", "impressions", "currency")
|
|
||||||
|
|
||||||
|
|
||||||
class PangleDateAgg(TypedDict):
|
|
||||||
date: str
|
|
||||||
revenue_yuan: float
|
|
||||||
api_revenue_yuan: float | None
|
|
||||||
impressions: int
|
|
||||||
|
|
||||||
|
|
||||||
def upsert_daily_rows(db: Session, rows: list[dict[str, Any]]) -> dict[str, int]:
|
|
||||||
"""按唯一键 (report_date, app_env, our_code_id, adn) 逐行 upsert。
|
|
||||||
|
|
||||||
每个 row 须含:report_date, app_env, our_code_id;可选 adn(默认"")、site_id、
|
|
||||||
revenue_yuan、api_revenue_yuan、ecpm、impressions、currency。返回 {inserted, updated}。
|
|
||||||
规模很小(代码位数×天数),逐行 select-then-write 足够,且 SQLite/PG 通用。
|
|
||||||
"""
|
|
||||||
inserted = updated = 0
|
|
||||||
for row in rows:
|
|
||||||
adn = row.get("adn") or ""
|
|
||||||
existing = db.execute(
|
|
||||||
select(AdPangleDailyRevenue).where(
|
|
||||||
AdPangleDailyRevenue.report_date == row["report_date"],
|
|
||||||
AdPangleDailyRevenue.app_env == row["app_env"],
|
|
||||||
AdPangleDailyRevenue.our_code_id == row["our_code_id"],
|
|
||||||
AdPangleDailyRevenue.adn == adn,
|
|
||||||
)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
|
|
||||||
if existing is None:
|
|
||||||
db.add(AdPangleDailyRevenue(
|
|
||||||
report_date=row["report_date"],
|
|
||||||
app_env=row["app_env"],
|
|
||||||
site_id=row.get("site_id"),
|
|
||||||
our_code_id=row["our_code_id"],
|
|
||||||
adn=adn,
|
|
||||||
revenue_yuan=float(row.get("revenue_yuan") or 0.0),
|
|
||||||
api_revenue_yuan=row.get("api_revenue_yuan"),
|
|
||||||
ecpm=row.get("ecpm"),
|
|
||||||
impressions=int(row.get("impressions") or 0),
|
|
||||||
currency=row.get("currency") or "cny",
|
|
||||||
))
|
|
||||||
inserted += 1
|
|
||||||
else:
|
|
||||||
for col in _UPDATABLE:
|
|
||||||
if col in row:
|
|
||||||
setattr(existing, col, row[col])
|
|
||||||
updated += 1
|
|
||||||
db.commit()
|
|
||||||
return {"inserted": inserted, "updated": updated}
|
|
||||||
|
|
||||||
|
|
||||||
def aggregate_by_date(
|
|
||||||
db: Session,
|
|
||||||
*,
|
|
||||||
date_from: str,
|
|
||||||
date_to: str,
|
|
||||||
app_env: str | None = None,
|
|
||||||
our_code_id: str | None = None,
|
|
||||||
) -> list[PangleDateAgg]:
|
|
||||||
"""按日期汇总穿山甲收益(闭区间,北京时间),供报表趋势 + 合计。
|
|
||||||
|
|
||||||
revenue_yuan = Σrevenue;api_revenue_yuan = Σapi_revenue(SQL SUM 忽略 NULL,
|
|
||||||
全为空则返回 None,前端显示「-」)。可选按应用 / 代码位过滤。返回按日期升序。
|
|
||||||
"""
|
|
||||||
stmt = (
|
|
||||||
select(
|
|
||||||
AdPangleDailyRevenue.report_date,
|
|
||||||
func.sum(AdPangleDailyRevenue.revenue_yuan),
|
|
||||||
func.sum(AdPangleDailyRevenue.api_revenue_yuan),
|
|
||||||
func.sum(AdPangleDailyRevenue.impressions),
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
AdPangleDailyRevenue.report_date >= date_from,
|
|
||||||
AdPangleDailyRevenue.report_date <= date_to,
|
|
||||||
)
|
|
||||||
.group_by(AdPangleDailyRevenue.report_date)
|
|
||||||
.order_by(AdPangleDailyRevenue.report_date)
|
|
||||||
)
|
|
||||||
if app_env is not None:
|
|
||||||
stmt = stmt.where(AdPangleDailyRevenue.app_env == app_env)
|
|
||||||
if our_code_id is not None:
|
|
||||||
stmt = stmt.where(AdPangleDailyRevenue.our_code_id == our_code_id)
|
|
||||||
|
|
||||||
out: list[PangleDateAgg] = []
|
|
||||||
for report_date, rev, api_rev, imp in db.execute(stmt).all():
|
|
||||||
out.append(PangleDateAgg(
|
|
||||||
date=report_date,
|
|
||||||
revenue_yuan=round(float(rev or 0.0), 6),
|
|
||||||
api_revenue_yuan=(round(float(api_rev), 6) if api_rev is not None else None),
|
|
||||||
impressions=int(imp or 0),
|
|
||||||
))
|
|
||||||
return out
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from sqlalchemy import delete, func, select
|
from sqlalchemy import delete, func, select
|
||||||
@@ -17,7 +17,6 @@ from app.models.coupon_state import (
|
|||||||
CouponClaimRecord,
|
CouponClaimRecord,
|
||||||
CouponDailyCompletion,
|
CouponDailyCompletion,
|
||||||
CouponPromptEngagement,
|
CouponPromptEngagement,
|
||||||
CouponSession,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("shagua.coupon_state")
|
logger = logging.getLogger("shagua.coupon_state")
|
||||||
@@ -233,91 +232,3 @@ def sum_claimed_count(db: Session, user_id: int) -> int:
|
|||||||
)
|
)
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
return int(total or 0)
|
return int(total or 0)
|
||||||
|
|
||||||
|
|
||||||
# ===== 领券任务流水(coupon_session,admin「领券数据」看板数据源)=====
|
|
||||||
|
|
||||||
def upsert_coupon_session(
|
|
||||||
db: Session,
|
|
||||||
*,
|
|
||||||
trace_id: str,
|
|
||||||
device_id: str,
|
|
||||||
status: str,
|
|
||||||
started_at_ms: int,
|
|
||||||
user_id: int | None = None,
|
|
||||||
platforms: list[str] | None = None,
|
|
||||||
origin_package: str | None = None,
|
|
||||||
device_model: str | None = None,
|
|
||||||
rom: str | None = None,
|
|
||||||
app_env: str | None = None,
|
|
||||||
elapsed_ms: int | None = None,
|
|
||||||
platform_elapsed: dict | None = None,
|
|
||||||
claimed_count: int | None = None,
|
|
||||||
trace_url: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""一条领券流水按 trace_id 幂等 upsert(发起 started 建行、收尾终态更新同一行)。
|
|
||||||
|
|
||||||
- 乱序/重复兜底:终态(completed/failed/abandoned)先到也建行;started 重复到不覆盖已有终态
|
|
||||||
(状态只前进,不降级)。
|
|
||||||
- started_at 由客户端墙钟毫秒转;started_date 取其 Asia/Shanghai 自然日(admin 按天聚合/筛选)。
|
|
||||||
- 终态帧补 finished_at=服务端 now;各字段非空才写(避免 started 帧的 None 抹掉收尾值,反之亦然)。
|
|
||||||
并发 IntegrityError 回滚忽略(本就幂等)。
|
|
||||||
"""
|
|
||||||
started_at = datetime.fromtimestamp(started_at_ms / 1000, tz=timezone.utc)
|
|
||||||
started_date = started_at.astimezone(_CN_TZ).date()
|
|
||||||
is_terminal = status in ("completed", "failed", "abandoned")
|
|
||||||
|
|
||||||
row = db.execute(
|
|
||||||
select(CouponSession).where(CouponSession.trace_id == trace_id)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
|
|
||||||
if row is None:
|
|
||||||
db.add(CouponSession(
|
|
||||||
trace_id=trace_id,
|
|
||||||
device_id=device_id,
|
|
||||||
user_id=user_id,
|
|
||||||
status=status,
|
|
||||||
app_env=app_env,
|
|
||||||
platforms=platforms,
|
|
||||||
origin_package=origin_package,
|
|
||||||
device_model=device_model,
|
|
||||||
rom=rom,
|
|
||||||
started_at=started_at,
|
|
||||||
started_date=started_date,
|
|
||||||
finished_at=datetime.now(timezone.utc) if is_terminal else None,
|
|
||||||
elapsed_ms=elapsed_ms,
|
|
||||||
platform_elapsed=platform_elapsed,
|
|
||||||
claimed_count=claimed_count,
|
|
||||||
trace_url=trace_url,
|
|
||||||
))
|
|
||||||
else:
|
|
||||||
# 状态只前进:started 帧重复到(如 START_STICKY 重启)不把已有终态降级回 started。
|
|
||||||
if not (status == "started" and row.status in ("completed", "failed", "abandoned")):
|
|
||||||
row.status = status
|
|
||||||
if is_terminal:
|
|
||||||
row.finished_at = datetime.now(timezone.utc)
|
|
||||||
if user_id is not None:
|
|
||||||
row.user_id = user_id
|
|
||||||
if platforms is not None:
|
|
||||||
row.platforms = platforms
|
|
||||||
if origin_package is not None:
|
|
||||||
row.origin_package = origin_package
|
|
||||||
if device_model is not None:
|
|
||||||
row.device_model = device_model
|
|
||||||
if rom is not None:
|
|
||||||
row.rom = rom
|
|
||||||
if app_env is not None:
|
|
||||||
row.app_env = app_env
|
|
||||||
if elapsed_ms is not None:
|
|
||||||
row.elapsed_ms = elapsed_ms
|
|
||||||
if platform_elapsed is not None:
|
|
||||||
row.platform_elapsed = platform_elapsed
|
|
||||||
if claimed_count is not None:
|
|
||||||
row.claimed_count = claimed_count
|
|
||||||
if trace_url is not None:
|
|
||||||
row.trace_url = trace_url
|
|
||||||
try:
|
|
||||||
db.commit()
|
|
||||||
except IntegrityError:
|
|
||||||
# 并发下另一请求刚插了同 trace_id → 唯一约束撞,回滚忽略(本就幂等)。
|
|
||||||
db.rollback()
|
|
||||||
|
|||||||
@@ -17,24 +17,12 @@ def create_feedback(
|
|||||||
content: str,
|
content: str,
|
||||||
contact: str,
|
contact: str,
|
||||||
images: list[str] | None,
|
images: list[str] | None,
|
||||||
source: str = "profile",
|
|
||||||
scene: str | None = None,
|
|
||||||
app_version: str | None = None,
|
|
||||||
device_model: str | None = None,
|
|
||||||
rom_name: str | None = None,
|
|
||||||
android_version: str | None = None,
|
|
||||||
) -> Feedback:
|
) -> Feedback:
|
||||||
fb = Feedback(
|
fb = Feedback(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
content=content,
|
content=content,
|
||||||
contact=contact,
|
contact=contact,
|
||||||
source=source,
|
|
||||||
scene=scene or None,
|
|
||||||
images=images or None,
|
images=images or None,
|
||||||
app_version=app_version or None,
|
|
||||||
device_model=device_model or None,
|
|
||||||
rom_name=rom_name or None,
|
|
||||||
android_version=android_version or None,
|
|
||||||
status="pending",
|
status="pending",
|
||||||
created_at=datetime.now(CN_TZ).replace(tzinfo=None),
|
created_at=datetime.now(CN_TZ).replace(tzinfo=None),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -216,21 +216,13 @@ def try_reward_on_compare(db: Session, invitee_user_id: int) -> CompareRewardRes
|
|||||||
def get_stats(db: Session, inviter_id: int) -> tuple[int, int]:
|
def get_stats(db: Session, inviter_id: int) -> tuple[int, int]:
|
||||||
"""返回 (已成功邀请人数, 累计从邀请获得的金币)。
|
"""返回 (已成功邀请人数, 累计从邀请获得的金币)。
|
||||||
|
|
||||||
"已邀请好友数"口径 = 完成一次比价(已触发邀请奖励金)的被邀请人数,即 compare_reward_granted=True。
|
|
||||||
不再数"仅绑定未比价"的关系——否则会出现"已邀请 1、可提现余额 0"(好友下载登录但没比价),
|
|
||||||
与产品口径"已邀请好友数 × 2元 = 累计提现 + 可提现余额"对不上。过滤后该恒等式天然成立
|
|
||||||
(每个计入的好友都恰好发过 1 笔 2 元,钱要么在余额要么已提现)。
|
|
||||||
|
|
||||||
金币口径(inviter_coin 之和)自 v3 起恒 0(邀请人收益改走邀请奖励金,见 get_reward_stats /
|
金币口径(inviter_coin 之和)自 v3 起恒 0(邀请人收益改走邀请奖励金,见 get_reward_stats /
|
||||||
try_reward_on_compare);保留返回位兼容旧响应字段 coins_earned。
|
try_reward_on_compare);保留返回位兼容旧响应字段 coins_earned。
|
||||||
"""
|
"""
|
||||||
count = db.execute(
|
count = db.execute(
|
||||||
select(func.count())
|
select(func.count())
|
||||||
.select_from(InviteRelation)
|
.select_from(InviteRelation)
|
||||||
.where(
|
.where(InviteRelation.inviter_user_id == inviter_id)
|
||||||
InviteRelation.inviter_user_id == inviter_id,
|
|
||||||
InviteRelation.compare_reward_granted.is_(True),
|
|
||||||
)
|
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
coins = db.execute(
|
coins = db.execute(
|
||||||
select(func.coalesce(func.sum(InviteRelation.inviter_coin), 0))
|
select(func.coalesce(func.sum(InviteRelation.inviter_coin), 0))
|
||||||
|
|||||||
@@ -610,7 +610,6 @@ def create_withdraw(
|
|||||||
source: str = "coin_cash",
|
source: str = "coin_cash",
|
||||||
user_name: str | None = None,
|
user_name: str | None = None,
|
||||||
out_bill_no: str | None = None,
|
out_bill_no: str | None = None,
|
||||||
allow_sub_min: bool = False,
|
|
||||||
) -> WithdrawOrder:
|
) -> WithdrawOrder:
|
||||||
"""发起提现:原子扣款 + 建单 reviewing(待人工审核),**不打款**。
|
"""发起提现:原子扣款 + 建单 reviewing(待人工审核),**不打款**。
|
||||||
|
|
||||||
@@ -620,13 +619,8 @@ def create_withdraw(
|
|||||||
重复发起多笔提现(审核拒绝再退回)。
|
重复发起多笔提现(审核拒绝再退回)。
|
||||||
#2 out_bill_no 客户端幂等键:同号重试返回该单现状(reviewing 等审核),不重复扣款建单。
|
#2 out_bill_no 客户端幂等键:同号重试返回该单现状(reviewing 等审核),不重复扣款建单。
|
||||||
实名 user_name 在此存下(WithdrawOrder.user_name),供异步审核打款时传给微信(达额需实名)。
|
实名 user_name 在此存下(WithdrawOrder.user_name),供异步审核打款时传给微信(达额需实名)。
|
||||||
|
|
||||||
allow_sub_min:放行低于"提现最低额"的小额(用于 0.01 元调试提现)。仅由 endpoint 在
|
|
||||||
`skip_review and not is_prod`(debug 包 + 非生产双闸)时置 True;仍受 schema gt=0 与 max 上限约束。
|
|
||||||
生产恒为 False → 最低额校验照常,绝不可能提 0.01。
|
|
||||||
"""
|
"""
|
||||||
min_c = 0 if allow_sub_min else rewards.get_withdraw_min_cents(db)
|
if amount_cents < rewards.get_withdraw_min_cents(db) or amount_cents > rewards.get_withdraw_max_cents(db):
|
||||||
if amount_cents < min_c or amount_cents > rewards.get_withdraw_max_cents(db):
|
|
||||||
raise InvalidWithdrawAmountError
|
raise InvalidWithdrawAmountError
|
||||||
|
|
||||||
# 提现即要求已绑微信:否则审核通过也打不了款,提前拦更友好
|
# 提现即要求已绑微信:否则审核通过也打不了款,提前拦更友好
|
||||||
|
|||||||
@@ -49,28 +49,3 @@ class CouponStatsOut(BaseModel):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
coupon_count: int
|
coupon_count: int
|
||||||
|
|
||||||
|
|
||||||
class CouponSessionIn(BaseModel):
|
|
||||||
"""客户端领券流水上报体(admin「领券数据」看板数据源,POST /api/v1/coupon/session)。
|
|
||||||
|
|
||||||
一次领券两段上报,按 trace_id upsert 到 coupon_session:
|
|
||||||
- 发起(status=started):带勾选平台 + 机型/ROM/app_env + started_at_ms(发起墙钟毫秒)。
|
|
||||||
- 收尾(completed/failed/abandoned):带 elapsed_ms(全程耗时)+ platform_elapsed(各平台耗时)+ claimed_count。
|
|
||||||
不鉴权(同领券循环 MVP,按 device_id/trace_id),user_id 登录态带上做留痕(可空)。
|
|
||||||
"""
|
|
||||||
|
|
||||||
trace_id: str
|
|
||||||
device_id: str
|
|
||||||
status: str # started / completed / failed / abandoned
|
|
||||||
started_at_ms: int # 发起墙钟毫秒(客户端 System.currentTimeMillis)
|
|
||||||
user_id: int | None = None
|
|
||||||
platforms: list[str] | None = None
|
|
||||||
origin_package: str | None = None
|
|
||||||
device_model: str | None = None
|
|
||||||
rom: str | None = None
|
|
||||||
app_env: str | None = None
|
|
||||||
elapsed_ms: int | None = None
|
|
||||||
platform_elapsed: dict[str, int] | None = None
|
|
||||||
claimed_count: int | None = None
|
|
||||||
trace_url: str | None = None
|
|
||||||
|
|||||||
@@ -30,14 +30,10 @@ class FeedbackQrConfigOut(BaseModel):
|
|||||||
class FeedbackRecordOut(BaseModel):
|
class FeedbackRecordOut(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
content: str
|
content: str
|
||||||
# 比价反馈的问题场景(找错商品/优惠不对…);普通反馈为 None
|
|
||||||
scene: str | None = None
|
|
||||||
images: list[str] = Field(default_factory=list)
|
images: list[str] = Field(default_factory=list)
|
||||||
status: str
|
status: str
|
||||||
reject_reason: str | None = None
|
reject_reason: str | None = None
|
||||||
reward_coins: int | None = None
|
reward_coins: int | None = None
|
||||||
# 运营给用户的回复留言(用户端可见,采纳/未采纳都可能有)
|
|
||||||
admin_reply: str | None = None
|
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 847 KiB |
+4
-13
@@ -198,7 +198,7 @@
|
|||||||
选择「<span class="guide-highlight">在浏览器打开</span>」
|
选择「<span class="guide-highlight">在浏览器打开</span>」
|
||||||
<span class="guide-final">在浏览器里按提示<span class="guide-target">去应用商店下载</span></span>
|
<span class="guide-final">在浏览器里按提示<span class="guide-target">去应用商店下载</span></span>
|
||||||
</h2>
|
</h2>
|
||||||
<!-- <div class="guide-dismiss" id="wxGuideDismiss">我知道了 ✕</div> -->
|
<div class="guide-dismiss" id="wxGuideDismiss">我知道了 ✕</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||||||
@@ -263,7 +263,7 @@
|
|||||||
var wxGuide = document.getElementById("wxGuide");
|
var wxGuide = document.getElementById("wxGuide");
|
||||||
function showWxGuide() { wxGuide.classList.add("show"); }
|
function showWxGuide() { wxGuide.classList.add("show"); }
|
||||||
function hideWxGuide() { wxGuide.classList.remove("show"); }
|
function hideWxGuide() { wxGuide.classList.remove("show"); }
|
||||||
// document.getElementById("wxGuideDismiss").addEventListener("click", hideWxGuide); // 「我知道了 ✕」已注释隐藏
|
document.getElementById("wxGuideDismiss").addEventListener("click", hideWxGuide);
|
||||||
if (isWeChat) showWxGuide(); // 微信里一进页面就提示去浏览器(微信内下载必被拦)
|
if (isWeChat) showWxGuide(); // 微信里一进页面就提示去浏览器(微信内下载必被拦)
|
||||||
|
|
||||||
function showToast(text) {
|
function showToast(text) {
|
||||||
@@ -273,24 +273,15 @@
|
|||||||
showToast.timer = setTimeout(function () { toast.classList.remove("show"); }, 1400);
|
showToast.timer = setTimeout(function () { toast.classList.remove("show"); }, 1400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 应用商店下载:微信内引导去浏览器;iOS 提示;安卓写邀请码 + 跳应用商店 =====
|
// ===== 下载按钮:微信内引导去浏览器;iOS 提示;安卓写邀请码 + 跳应用商店 =====
|
||||||
function handleDownload() {
|
function handleDownload() {
|
||||||
if (isWeChat) { showWxGuide(); return; }
|
if (isWeChat) { showWxGuide(); return; }
|
||||||
if (isIOS) { alert("iOS 版即将上线,请前往 App Store 搜索「傻瓜比价」"); return; }
|
if (isIOS) { alert("iOS 版即将上线,请前往 App Store 搜索「傻瓜比价」"); return; }
|
||||||
copyInviteCode(); // 先把邀请码写进剪贴板(供 App 首启归因),链路丢了还有 landing-track 指纹兜底
|
copyInviteCode(); // 先把邀请码写进剪贴板(供 App 首启归因),链路丢了还有 landing-track 指纹兜底
|
||||||
openStore();
|
openStore();
|
||||||
}
|
}
|
||||||
// ===== 官网下载:微信/iOS 同上引导;安卓直接跳 APK 直链 → 弹系统下载弹窗 =====
|
|
||||||
// APK_URL 跟随页面 host:本地走 LAN、生产走 app-api.shaguabijia.com(见邀请功能文档约定)。
|
|
||||||
var APK_URL = location.origin + "/media/shaguabijia.apk";
|
|
||||||
function handleWebsiteDownload() {
|
|
||||||
if (isWeChat) { showWxGuide(); return; }
|
|
||||||
if (isIOS) { alert("iOS 版即将上线,请前往 App Store 搜索「傻瓜比价」"); return; }
|
|
||||||
copyInviteCode(); // 同样先写邀请码进剪贴板(供 App 首启归因)
|
|
||||||
window.location.href = APK_URL;
|
|
||||||
}
|
|
||||||
document.getElementById("dlbtn").addEventListener("click", handleDownload);
|
document.getElementById("dlbtn").addEventListener("click", handleDownload);
|
||||||
document.getElementById("dlbtn2").addEventListener("click", handleWebsiteDownload);
|
document.getElementById("dlbtn2").addEventListener("click", handleDownload);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 35 KiB |
@@ -1,67 +0,0 @@
|
|||||||
# 穿山甲 GroMore 收益拉取 定时任务 — 运维手册
|
|
||||||
|
|
||||||
> 对象:维护「每天拉穿山甲后台收益入库」这套定时任务的同事。
|
|
||||||
> 🔒 服务器登录信息见**私密交接清单**,不入库。
|
|
||||||
|
|
||||||
## 它是什么
|
|
||||||
admin「广告收益报表」里的「穿山甲后台收益(T+1)」读的是**本地表 `ad_pangle_daily_revenue` 的快照,不是实时查穿山甲**。穿山甲只通过 GroMore 数据 API 给数、且 **T+1**(次日约 10:00 出昨天的数),所以每天得拉一次入库,报表才会往前走。
|
|
||||||
|
|
||||||
- 每天 10:30 跑一轮 `scripts/sync_pangle_revenue.py`,默认 `--days 3` 回补近 3 天。
|
|
||||||
- 维度 = 日期 × 应用(site_id)× 广告位(ad_unit_id);指标 = `revenue`(预估)+ `api_revenue`(结算口径)。
|
|
||||||
- **幂等 upsert**:同一(日期×应用×代码位)重跑只覆盖、不重复,故回补 / 重跑 / catch-up 都安全。
|
|
||||||
- 穿山甲无用户/设备维度 → 只能落「汇总/趋势级」,报表带 user_id 过滤时这块收益置空(显示「-」)。
|
|
||||||
|
|
||||||
## 文件
|
|
||||||
| 项 | 路径 |
|
|
||||||
|---|---|
|
|
||||||
| 脚本入口 | `scripts/sync_pangle_revenue.py` |
|
|
||||||
| 拉取 / 签名 | `app/integrations/pangle_report.py`(签名=参数字典序拼接+secure_key 后 MD5) |
|
|
||||||
| 入库表 | `ad_pangle_daily_revenue`(读写在 `app/repositories/ad_pangle_revenue.py`) |
|
|
||||||
| 凭证 | `.env` 的 `PANGLE_REPORT_USER_ID` / `PANGLE_REPORT_ROLE_ID` / `PANGLE_REPORT_SECURITY_KEY` |
|
|
||||||
| 应用映射 | `.env` 的 `PANGLE_REPORT_SITE_ID_PROD`(5830519)/ `PANGLE_REPORT_SITE_ID_TEST`(5832303) |
|
|
||||||
| systemd 单元 | `deploy/pangle-revenue.{service,timer}` |
|
|
||||||
|
|
||||||
## 上线前置(只做一次)
|
|
||||||
1. **填凭证**:后台「接入中心 → GroMore-API」领 user_id / role_id / Security Key(`secure_key`,**≠ 发奖 m-key**),填进线上 `.env` 的 `PANGLE_REPORT_*`。三项填齐脚本才工作,缺任一 → no-op 退出。
|
|
||||||
2. **子账号要授权**:线上若用子账号(role_id ≠ user_id),需主账号给它授「查看全部数据」,否则接口报 **118**(无权限)。本人自查自己(user_id=role_id)无此问题。
|
|
||||||
3. 凭证泄露:后台「重置 key 值」即可,改完同步线上 `.env` 重跑。
|
|
||||||
|
|
||||||
## 部署(Linux 服务器,需 root)
|
|
||||||
```bash
|
|
||||||
sudo cp deploy/pangle-revenue.{service,timer} /etc/systemd/system/
|
|
||||||
sudo systemctl daemon-reload && sudo systemctl enable --now pangle-revenue.timer
|
|
||||||
systemctl list-timers pangle-revenue.timer # 确认下次触发时间(应是次日 10:30)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 怎么看健康 / 手动跑一次
|
|
||||||
```bash
|
|
||||||
sudo systemctl start pangle-revenue.service # 立即手动跑一轮(不等 10:30)
|
|
||||||
journalctl -u pangle-revenue -n 30 --no-pager # 看日志:拉取区间 / 入库行数 / 新增更新 / 预估收益合计
|
|
||||||
```
|
|
||||||
成功日志形如:`✅ 完成:接口 N 行 → 入库 M 行(跳过 x),新增 a / 更新 b;预估收益合计 ¥19.42`。
|
|
||||||
> 看不到收益、提示 `PANGLE_REPORT_* 未配置`→ 回「上线前置」补 `.env`;报 118 → 子账号没授「查看全部数据」。
|
|
||||||
|
|
||||||
## 本机 Windows 开发(无 systemd)
|
|
||||||
直接手动跑:
|
|
||||||
```
|
|
||||||
.venv\Scripts\python -m scripts.sync_pangle_revenue # 拉昨天
|
|
||||||
.venv\Scripts\python -m scripts.sync_pangle_revenue --days 3 # 回补近 3 天
|
|
||||||
.venv\Scripts\python -m scripts.sync_pangle_revenue --date 2026-06-27 # 指定单天
|
|
||||||
.venv\Scripts\python -m scripts.sync_pangle_revenue --start 2026-06-01 --end 2026-06-27 # 区间回补
|
|
||||||
```
|
|
||||||
|
|
||||||
## 脚本参数
|
|
||||||
- 无参:拉**昨天**(北京时间)。timer 用的是 `--days 3`。
|
|
||||||
- `--days N`:从昨天起往前回补 N 天(含昨天)。
|
|
||||||
- `--date YYYY-MM-DD`:指定单天。
|
|
||||||
- `--start / --end`:指定闭区间(跨度 ≤ 31 天,接口上限 1 个月,超了报 114)。
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
- **触发时间**:`OnCalendar=*-*-* 10:30:00`。穿山甲 ~10:00 出数,故别早于 10:00 跑(会拉到空/不全)。
|
|
||||||
- **catch-up**:`Persistent=true` 补跑错过的那一轮;叠加 `--days 3`,漏一两天重新触发即自愈。
|
|
||||||
- **今天 / 今天以前要分开查**:脚本默认只拉昨天及更早,不混查今天(接口约束),无需关心。
|
|
||||||
- **join key 是 `ad_unit_id`(我们配的 104xxx)不是 `code_id`**:`code_id` 是底层各 ADN 代码位,对不上口径;`ad_unit_id='-1'` 是未归因桶。改维度时务必注意(详见脚本头注释)。
|
|
||||||
- **`api_revenue` 很稀疏**:测试应用 ADN 没配 Reporting → 全 0,仅 prod 个别位有;`revenue`(预估)才是稳的主力。
|
|
||||||
- **DB 无关**:sqlite / postgres 均可(upsert 逐行 select-then-write,不像美团 ETL 需要 PG)。
|
|
||||||
- **别和别的触发方式双跑**:本 systemd timer 与「手动 cron / 进程内任务」二选一,虽幂等不会重复入库,纯属多余。
|
|
||||||
- **改脚本 / 改部署**:走 git + PR,由有 root 的人部署。
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# 每天拉穿山甲 GroMore T+1 天级收益入库 —— 单轮跑,由 pangle-revenue.timer 每天 10:30 触发。
|
|
||||||
# 落 ad_pangle_daily_revenue 表,供 admin 广告收益报表的「穿山甲后台收益(T+1)」区块。
|
|
||||||
#
|
|
||||||
# 仅用于 Linux 服务器;本机 Windows 开发无 systemd,直接手动跑脚本即可:
|
|
||||||
# .venv\Scripts\python -m scripts.sync_pangle_revenue # 拉昨天(北京时间)
|
|
||||||
# .venv\Scripts\python -m scripts.sync_pangle_revenue --days 3 # 回补近 3 天
|
|
||||||
#
|
|
||||||
# 部署(服务器):
|
|
||||||
# sudo cp deploy/pangle-revenue.{service,timer} /etc/systemd/system/
|
|
||||||
# sudo systemctl daemon-reload && sudo systemctl enable --now pangle-revenue.timer
|
|
||||||
# # 手动跑一次验证: sudo systemctl start pangle-revenue.service && journalctl -u pangle-revenue -n 30
|
|
||||||
#
|
|
||||||
# 前置:.env 配好 PANGLE_REPORT_USER_ID / PANGLE_REPORT_ROLE_ID / PANGLE_REPORT_SECURITY_KEY
|
|
||||||
# (后台「接入中心 → GroMore-API」领;子账号需主账号授「查看全部数据」否则接口 118)。
|
|
||||||
# 未配齐这三项 → 脚本自动 no-op 退出,免动 timer。
|
|
||||||
[Unit]
|
|
||||||
Description=Sync Pangle GroMore daily revenue (T+1, one-shot, driven by timer)
|
|
||||||
After=network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=oneshot
|
|
||||||
User=root
|
|
||||||
WorkingDirectory=/opt/shaguabijia-app-server
|
|
||||||
Environment="PATH=/opt/shaguabijia-app-server/.venv/bin:/usr/bin:/bin"
|
|
||||||
EnvironmentFile=/opt/shaguabijia-app-server/.env
|
|
||||||
# 默认拉昨天;--days 3 回补近 3 天(幂等 upsert,应对偶发漏跑 + 穿山甲对历史数据订正,重跑无害)。
|
|
||||||
ExecStart=/opt/shaguabijia-app-server/.venv/bin/python -m scripts.sync_pangle_revenue --days 3
|
|
||||||
SyslogIdentifier=pangle-revenue
|
|
||||||
# 仅几个 HTTP 请求 + 小批量入库,通常数秒;给 10min 硬超时防穿山甲接口卡死。
|
|
||||||
TimeoutStartSec=600
|
|
||||||
|
|
||||||
# 与主服务 shaguabijia-app-server.service 同款加固。
|
|
||||||
NoNewPrivileges=true
|
|
||||||
PrivateTmp=true
|
|
||||||
ProtectSystem=strict
|
|
||||||
ReadWritePaths=/opt/shaguabijia-app-server
|
|
||||||
ProtectHome=true
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# 每天 10:30 触发一次穿山甲 GroMore T+1 收益拉取入库(Linux 服务器用)。
|
|
||||||
# 见 pangle-revenue.service 顶部注释的部署步骤。
|
|
||||||
[Unit]
|
|
||||||
Description=Run Pangle GroMore daily revenue sync at 10:30
|
|
||||||
|
|
||||||
[Timer]
|
|
||||||
# 穿山甲 T+1、次日约 10:00 出数;10:30 触发留 30min 余量。要错开整点扎堆可微调到 10:35。
|
|
||||||
OnCalendar=*-*-* 10:30:00
|
|
||||||
# 服务器宕机/重启后,补跑错过的那一轮(而不是干等次日);叠加 --days 3 回补,漏一两天能自愈。
|
|
||||||
Persistent=true
|
|
||||||
AccuracySec=1min
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=timers.target
|
|
||||||
+22
-73
@@ -1,83 +1,32 @@
|
|||||||
# 文档索引
|
# 后端文档库(docs/)
|
||||||
|
|
||||||
项目文档结构说明。后续大模型增补/更新文档时,按此分类找到对应目录。
|
`shaguabijia-app-server` 的文档都在这里。结构:**根目录放总览,其余按领域/用途分目录**。
|
||||||
|
|
||||||
---
|
## 根目录
|
||||||
|
|
||||||
## API 接口文档 (`api/`)
|
| 文档 | 作用 |
|
||||||
|
|---|---|
|
||||||
|
| [后端技术实现.md](./后端技术实现.md) | **后端技术方案总览**:业务概览、分层架构与目录、登录链路、美团 CPS、领券/比价透传、数据模型、配置与部署、已知问题。想了解"整个后端怎么回事"先看这份。 |
|
||||||
|
| README.md | 本文件:文档库索引/传送门。 |
|
||||||
|
|
||||||
按业务领域分类,每个子目录对应一类接口。
|
## 子目录
|
||||||
|
|
||||||
### 分类目录
|
| 目录 | 作用 |
|
||||||
|
|---|---|
|
||||||
|
| [api/](./api/) | **API 接口文档**:"索引 + 一接口一文件"(组织方式见下)。想查"某个接口的协议"看这里。 |
|
||||||
|
| [database/](./database/) | **数据库文档**:每张表一个文件(表结构/字段/索引/约定) + 两份迁移指南——[数据库迁移.md](./database/数据库迁移.md)(Alembic 操作:clone 后建表/日常升级/新增迁移/多 head 排查) + [postgres-migration.md](./database/postgres-migration.md)(SQLite → PostgreSQL 切换步骤,配套 `scripts/init_postgres.py`)。想"把库跑起来 / 改表结构 / 查某张表"看这里。 |
|
||||||
|
| [integrations/](./integrations/) | **集成层实现文档**:穿山甲验签 / 微信支付 / 极光 / 短信 / 美团 CPS 等 SDK 集成的签名、加解密、协议细节与踩坑。想知道"接外部服务那块到底怎么实现"看这里。 |
|
||||||
|
| [guides/](./guides/) | **开发 / 上线 / 功能 指南**:[待办与技术债.md](./guides/待办与技术债.md)(跨前后端 backlog,P1 鉴权/用户绑定、引擎移植待办等,"还欠什么、以后要补什么"看这份) + [看广告赚金币上线清单.md](./guides/看广告赚金币上线清单.md)(看广告发奖上线 checklist) + [邀请功能-实现原理与本地测试.md](./guides/邀请功能-实现原理与本地测试.md)(invite-mvp 实现原理 + 本地内网全链路测试) + [CPS发券分发与微信授权.md](./guides/CPS发券分发与微信授权.md)(**CPS 发券系统交接文档**:设群/建活动/生成落地页短链/美团对账/统计 + 微信网页授权拿 openid 做用户级统计;含数据流/表/平台差异/端点/配置/代码地图/排障/技术债)。 |
|
||||||
|
|
||||||
| 目录 | 分类 | URL 前缀 | 说明 |
|
## api/ 目录是怎么组织的(传送门式)
|
||||||
|------|------|----------|------|
|
|
||||||
| [api/auth/](api/auth/) | 认证 | `/api/v1/auth` | 用户登录(极光一键登录/短信验证码)、Token 刷新(access + refresh)、登出、当前用户信息查询 |
|
|
||||||
| [api/ad/](api/ad/) | 广告 | `/api/v1/ad` | 穿山甲 S2S 回调验签发奖、激励视频/信息流广告奖励结算、eCPM 上报、发奖状态查询、测试发奖(仅本地) |
|
|
||||||
| [api/wallet/](api/wallet/) | 钱包 | `/api/v1/wallet` | 账户资产查询(金币/现金余额)、金币与现金流水、兑换规则与执行、绑定/解绑微信、提现申请/状态/记录、免确认收款授权 |
|
|
||||||
| [api/coupon/](api/coupon/) | 领券 | `/api/v1/coupon` | CPS 领券透传(step/session)、引导窗频控(should-show/shown/dismiss/reset)、每日完成状态、累计领券统计 |
|
|
||||||
| [api/compare/](api/compare/) | 比价记录 | `/api/v1/compare` | 比价记录上报/列表/详情/统计、比价战绩里程碑查询及奖励领取(按成功比价数解锁金币) |
|
|
||||||
| [api/savings/](api/savings/) | 省钱 | `/api/v1/savings` + `/api/v1/platform/savings-feed` | 省钱大作战(battle)、省钱明细/汇总、平台省钱动态 Feed |
|
|
||||||
| [api/signin/](api/signin/) | 签到 | `/api/v1/signin` | 每日签到执行、签到加速(看广告多领)、签到状态查询 |
|
|
||||||
| [api/tasks/](api/tasks/) | 任务 | `/api/v1/tasks` | 任务列表查询、任务奖励领取(按 task_key) |
|
|
||||||
| [api/invite/](api/invite/) | 邀请 | `/api/v1/invite` | 邀请码/分享链接生成、已邀请列表、邀请绑定(支持 clipboard/manual/fingerprint 三种归因) |
|
|
||||||
| [api/user/](api/user/) | 用户 | `/api/v1/user` | 个人资料编辑(昵称)、头像上传、新手引导状态/完成标记、注销账号 |
|
|
||||||
| [api/device/](api/device/) | 设备 | `/api/v1/device` | 设备注册/推送 token 更新、无障碍存活心跳、掉线检测(后置 pull)、告警确认 |
|
|
||||||
| [api/platform/](api/platform/) | 平台配置 | `/api/v1/platform` | 平台统计数据、Feature Flag 开关、广告配置(穿山甲 ID)、App 版本更新检查(OTA);全部不鉴权 |
|
|
||||||
| [api/intent/](api/intent/) | 意图/电商 | `/api/v1/intent` + `/api/v1/price` + `/api/v1/ecom` | 比价意图识别(Phase 1 单次/多帧/预券)、电商意图识别、比价步进(Phase 2) |
|
|
||||||
| [api/meituan/](api/meituan/) | 美团 CPS | `/api/v1/meituan` | 美团券列表/信息流/推广链接/销量榜;全部无鉴权 |
|
|
||||||
| [api/other/](api/other/) | 其它 | 分散 | 健康检查、CPS 短链重定向(含微信 OAuth)、反馈提交/配置/记录、埋点上报、订单上报、更低价上报、Trace 收尾 |
|
|
||||||
| [api/admin/](api/admin/) | 管理后台 | `/admin/api` | Admin 独立子应用,二级目录按子资源拆分:`auth/`(登录)、`users/`(用户管理/财务操作)、`wallet/`(流水查询)、`withdraws/`(提现审核/对账)、`feedbacks/`(反馈处理)、`admins/`(管理员管理)、`ad/`(广告对账/收益);单文件留根:审计日志、数据大盘、跑马灯种子、统计概览 |
|
|
||||||
| [api/internal/](api/internal/) | 内部接口 | `/internal` | 服务间调用(pricebot→app-server):价格事实回写、店铺映射、启动确认样本、App 版本写入;X-Internal-Secret 鉴权 |
|
|
||||||
|
|
||||||
### 接口索引入口
|
接口多了之后,单个大文件会臃肿、难维护,所以拆成 **一个索引 + 一个接口一个文件**:
|
||||||
|
|
||||||
完整接口列表(含路径、方法、鉴权方式)见 [api/README.md](api/README.md)。
|
- **[api/README.md](./api/README.md) — 入口/传送门**
|
||||||
|
一张总览表列出**全部接口**(方法、路径、鉴权),每行链接到该接口的独立文档;另含**通用约定**(错误码、时间格式等)和**复用数据结构**(`TokenPair` / `UserOut` / `CouponCard` 等)。它本身不展开每个接口的细节,只负责"指路"。
|
||||||
|
- **api/<模块>-<接口>.md — 单接口文档**
|
||||||
|
每个接口一个文件,只写自己的入参 / 出参 / 错误码 / 说明。命名按 `<模块>-<接口>`,如 `auth-jverify-login.md`、`coupon-step.md`、`meituan-feed.md`。
|
||||||
|
|
||||||
### 文档命名规则
|
**查某个接口的协议**:先打开 [api/README.md](./api/README.md) 的总览表 → 找到接口 → 点链接进对应文档。
|
||||||
|
|
||||||
单端点文档按 URL 路径命名:`{prefix}-{resource}.md`(如 `wallet-withdraw.md`、`auth-sms-send.md`)。
|
> **新增接口时**:在 `api/` 下加一个 `<模块>-<接口>.md`,并到 `api/README.md` 总览表里补一行链接。这样索引始终是唯一的"传送门",细节各自独立、互不干扰。
|
||||||
含子资源的合并文档按族命名(如 `device-liveness.md` 覆盖 register/heartbeat/liveness/liveness-ack 四个端点)。
|
|
||||||
文件名唯一确定文档位置:大类目录 + 文件名前缀 → 直接匹配。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 数据库文档 (`database/`)
|
|
||||||
|
|
||||||
数据库表结构说明,每表一个文件,[database/OVERVIEW.md](database/OVERVIEW.md) 为索引入口。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 第三方集成 (`integrations/`)
|
|
||||||
|
|
||||||
外部 SDK/API 集成架构与实现说明:[integrations/README.md](integrations/README.md)。
|
|
||||||
|
|
||||||
| 文件 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| [jiguang.md](integrations/jiguang.md) | 极光一键登录(REST 验证 + RSA 解密) |
|
|
||||||
| [pangle.md](integrations/pangle.md) | 穿山甲广告 S2S 回调 |
|
|
||||||
| [wxpay.md](integrations/wxpay.md) | 微信支付 V3(提现/授权) |
|
|
||||||
| [meituan.md](integrations/meituan.md) | 美团 CPS 网关 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 开发指南 (`guides/`)
|
|
||||||
|
|
||||||
| 文件 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| [邀请功能-实现原理与本地测试.md](guides/邀请功能-实现原理与本地测试.md) | 邀请系统实现细节 |
|
|
||||||
| [CPS发券分发与微信授权.md](guides/CPS发券分发与微信授权.md) | CPS 发券 + 微信网页授权流程 |
|
|
||||||
| [看广告赚金币上线清单.md](guides/看广告赚金币上线清单.md) | 广告功能上线检查清单 |
|
|
||||||
| [待办与技术债.md](guides/待办与技术债.md) | 待办事项与技术债务 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 架构与设计 (`superpowers/`)
|
|
||||||
|
|
||||||
需求规格与设计文档,按 `YYYY-MM-DD-<topic>-design.md` 命名。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 后端技术实现 (`后端技术实现.md`)
|
|
||||||
|
|
||||||
后端整体技术架构说明。
|
|
||||||
|
|||||||
+121
-129
@@ -3,7 +3,7 @@
|
|||||||
> Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770`
|
> Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770`
|
||||||
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**
|
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**
|
||||||
> 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer <access_token>`
|
> 鉴权:需鉴权的接口在请求头带 `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-06-23(补全此前缺整族的端点:device 无障碍存活监控、internal 内部回写、CPS 短链落地、report/invite/wxpay,及 platform/coupon/feedback/user 的零散读端点;device/internal/cps-redirect 三族新建单文件文档。上一次 2026-06-18 补录比价透传端点与 `meituan/top-sales`)
|
||||||
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
|
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -12,154 +12,146 @@
|
|||||||
|
|
||||||
| # | 方法 + 路径 | 鉴权 | 详情 |
|
| # | 方法 + 路径 | 鉴权 | 详情 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 1 | `GET /health` | 无 | [详情](./other/health.md) |
|
| 1 | `GET /health` | 无 | [详情](./health.md) |
|
||||||
| 2 | `POST /api/v1/auth/jverify-login` | 无 | [详情](./auth/auth-jverify-login.md) |
|
| 2 | `POST /api/v1/auth/jverify-login` | 无 | [详情](./auth-jverify-login.md) |
|
||||||
| 3 | `POST /api/v1/auth/sms/send` | 无 | [详情](./auth/auth-sms-send.md) |
|
| 3 | `POST /api/v1/auth/sms/send` | 无 | [详情](./auth-sms-send.md) |
|
||||||
| 4 | `POST /api/v1/auth/sms/login` | 无 | [详情](./auth/auth-sms-login.md) |
|
| 4 | `POST /api/v1/auth/sms/login` | 无 | [详情](./auth-sms-login.md) |
|
||||||
| 5 | `POST /api/v1/auth/refresh` | 无 | [详情](./auth/auth-refresh.md) |
|
| 5 | `POST /api/v1/auth/refresh` | 无 | [详情](./auth-refresh.md) |
|
||||||
| 6 | `GET /api/v1/auth/me` | Bearer | [详情](./auth/auth-me.md) |
|
| 6 | `GET /api/v1/auth/me` | Bearer | [详情](./auth-me.md) |
|
||||||
| 7 | `POST /api/v1/auth/logout` | Bearer | [详情](./auth/auth-logout.md) |
|
| 7 | `POST /api/v1/auth/logout` | Bearer | [详情](./auth-logout.md) |
|
||||||
| 8 | `POST /api/v1/coupon/step` | 无 | [详情](./coupon/coupon-step.md)(透传 pricebot + best-effort 写 `coupon_*` 三表) |
|
| 8 | `POST /api/v1/coupon/step` | 无 | [详情](./coupon-step.md)(透传 pricebot + best-effort 写 `coupon_*` 三表) |
|
||||||
| 8a | `POST /api/v1/coupon/prompt/shown` | 无 | [详情](./coupon/coupon-prompt.md)(引导窗弹出即上报) |
|
| 8a | `POST /api/v1/coupon/prompt/shown` | 无 | 引导窗弹出即上报(按 device+package+日记 `shown`,今天这个 App 不再自动弹)(无单独文档) |
|
||||||
| 8b | `POST /api/v1/coupon/prompt/dismiss` | 无 | [详情](./coupon/coupon-prompt.md)(用户拒绝/关闭引导窗) |
|
| 8b | `POST /api/v1/coupon/prompt/dismiss` | 无 | 用户拒绝/关闭引导窗(透传链路看不到拒绝,客户端通知)(无单独文档) |
|
||||||
| 8c | `GET /api/v1/coupon/prompt/should-show` | 无 | [详情](./coupon/coupon-prompt.md)(切到外卖 App 时是否还应弹引导窗) |
|
| 8c | `GET /api/v1/coupon/prompt/should-show` | 无 | 切到外卖 App 时是否还应弹引导窗(`device_id`+`package`)(无单独文档) |
|
||||||
| 8d | `POST /api/v1/coupon/prompt/reset` | 无 | [详情](./coupon/coupon-prompt.md)(重置今日引导窗 engagement,开发测频控用) |
|
| 8d | `POST /api/v1/coupon/prompt/reset` | 无 | 重置今日引导窗 engagement(开发测频控用)(无单独文档) |
|
||||||
| 8e | `GET /api/v1/coupon/completed-today` | 无 | [详情](./coupon/coupon-completed-today.md)(这台设备今天是否已跑完整轮领券) |
|
| 8e | `GET /api/v1/coupon/completed-today` | 无 | 这台设备今天是否已跑完整轮领券(首页「去领取」卡置灰源)(无单独文档) |
|
||||||
| 8f | `POST /api/v1/coupon/completed-today/reset` | 无 | [详情](./coupon/coupon-completed-today.md)(重置今日已完成,开发用) |
|
| 8f | `POST /api/v1/coupon/completed-today/reset` | 无 | 重置今日已完成(开发用)(无单独文档) |
|
||||||
| 8g | `GET /api/v1/coupon/stats` | Bearer | [详情](./coupon/coupon-stats.md)(累计领券数,「我的」页战绩卡) |
|
| 8g | `GET /api/v1/coupon/stats` | Bearer | 累计领券数(「我的」页战绩卡;按 user_id 聚合,**鉴权**)(无单独文档) |
|
||||||
| 8h | `POST /api/v1/coupon/session` | 无 | [详情](./coupon/coupon/coupon-session.md)(领券流水上报,admin 看板数据源) |
|
| 9 | `POST /api/v1/meituan/coupons` | 无 | [详情](./meituan-coupons.md) |
|
||||||
| 9 | `POST /api/v1/meituan/coupons` | 无 | [详情](./meituan/meituan-coupons.md) |
|
| 10 | `POST /api/v1/meituan/feed` | 无 | [详情](./meituan-feed.md) |
|
||||||
| 10 | `POST /api/v1/meituan/feed` | 无 | [详情](./meituan/meituan-feed.md) |
|
| 11 | `POST /api/v1/meituan/referral-link` | 无 | [详情](./meituan-referral-link.md) |
|
||||||
| 11 | `POST /api/v1/meituan/referral-link` | 无 | [详情](./meituan/meituan-referral-link.md) |
|
| 11a | `POST /api/v1/meituan/top-sales` | 无 | [详情](./meituan-top-sales.md)(销量榜:离线库 `meituan_coupon` 按销量降序 + 跨源去重,不实时打美团) |
|
||||||
| 11a | `POST /api/v1/meituan/top-sales` | 无 | [详情](./meituan/meituan-top-sales.md)(销量榜:离线库 `meituan_coupon` 按销量降序 + 跨源去重,不实时打美团) |
|
|
||||||
| **比价透传**(前缀 `/api/v1`,外卖 MVP;与 `coupon/step` 同为透传 pricebot-backend;下按 Phase 流程列,均不鉴权) |||
|
| **比价透传**(前缀 `/api/v1`,外卖 MVP;与 `coupon/step` 同为透传 pricebot-backend;下按 Phase 流程列,均不鉴权) |||
|
||||||
| 12 | `POST /api/v1/intent/recognize` | 无 | [详情](./intent/compare-intent-recognize.md)(Phase 1 意图识别,单次,多数源) |
|
| 12 | `POST /api/v1/intent/recognize` | 无 | [详情](./compare-intent-recognize.md)(Phase 1 意图识别,单次,多数源) |
|
||||||
| 12a | `POST /api/v1/intent/precoupon/step` | 无 | [详情](./intent/intent-step.md)(Phase 0 意图识别前先用券,仅美团源) |
|
| 12a | `POST /api/v1/intent/precoupon/step` | 无 | Phase 0 意图识别前先用券,仅美团源(透传,无单独文档) |
|
||||||
| 12b | `POST /api/v1/intent/step` | 无 | [详情](./intent/intent-step.md)(Phase 1 多帧意图识别,仅淘宝源,循环到 done) |
|
| 12b | `POST /api/v1/intent/step` | 无 | Phase 1 多帧意图识别,仅淘宝源,循环到 done(透传,无单独文档) |
|
||||||
| 13 | `POST /api/v1/price/step` | 无 | [详情](./intent/compare-price-step.md)(Phase 2 步进) |
|
| 13 | `POST /api/v1/price/step` | 无 | [详情](./compare-price-step.md)(Phase 2 步进) |
|
||||||
| 13a | `POST /api/v1/trace/finalize` | 无 | [详情](./other/trace-finalize.md)(比价 trace 收尾上云,终止/未识别拿 trace_url) |
|
| 13a | `POST /api/v1/trace/finalize` | 无 | 比价 trace 收尾上云,终止/未识别拿 trace_url(透传,无单独文档) |
|
||||||
| **比价记录**(前缀 `/api/v1/compare`;按用户落库,**鉴权**,区别于上面不鉴权的透传) |||
|
| **比价记录**(前缀 `/api/v1/compare`;按用户落库,**鉴权**,区别于上面不鉴权的透传) |||
|
||||||
| 12a | `POST /api/v1/compare/record` | Bearer | [详情](./compare/compare-record-report.md) |
|
| 12a | `POST /api/v1/compare/record` | Bearer | [详情](./compare-record-report.md) |
|
||||||
| 12b | `GET /api/v1/compare/records` | Bearer | [详情](./compare/compare-records.md) |
|
| 12b | `GET /api/v1/compare/records` | Bearer | [详情](./compare-records.md) |
|
||||||
| 12c | `GET /api/v1/compare/records/{id}` | Bearer | [详情](./compare/compare-record-detail.md) |
|
| 12c | `GET /api/v1/compare/records/{id}` | Bearer | [详情](./compare-record-detail.md) |
|
||||||
| 12e | `GET /api/v1/compare/stats` | Bearer | [详情](./compare/compare-stats.md)(「我的」页省钱战绩卡:完成比价数 + 累计发现可省) |
|
| 12e | `GET /api/v1/compare/stats` | Bearer | [详情](./compare-stats.md)(「我的」页省钱战绩卡:完成比价数 + 累计发现可省) |
|
||||||
| **比价战绩里程碑**(前缀 `/api/v1/compare`;福利页「记录比价战绩」,按成功比价数解锁逐档发金币) |||
|
| **比价战绩里程碑**(前缀 `/api/v1/compare`;福利页「记录比价战绩」,按成功比价数解锁逐档发金币) |||
|
||||||
| 12d | `GET /api/v1/compare/milestones` | Bearer | [详情](./compare/compare-milestones.md) |
|
| 12d | `GET /api/v1/compare/milestones` | Bearer | [详情](./compare-milestones.md) |
|
||||||
| 12e | `POST /api/v1/compare/milestones/{milestone}/claim` | Bearer | [详情](./compare/compare-milestone-claim.md) |
|
| 12e | `POST /api/v1/compare/milestones/{milestone}/claim` | Bearer | [详情](./compare-milestone-claim.md) |
|
||||||
| **设备 / 无障碍存活监控**(前缀 `/api/v1/device`;心跳超时检出 + 掉线召回,#65) |||
|
| **设备 / 无障碍存活监控**(前缀 `/api/v1/device`;心跳超时检出 + 掉线召回,#65) |||
|
||||||
| D1 | `POST /api/v1/device/register` | Bearer | [详情](./device/device-liveness.md)(注册设备/更新极光 push token) |
|
| D1 | `POST /api/v1/device/register` | Bearer | [详情](./device-liveness.md)(注册设备/更新极光 push token) |
|
||||||
| D2 | `POST /api/v1/device/heartbeat` | Bearer | [详情](./device/device-liveness.md)(无障碍服务存活心跳,心跳也能自注册) |
|
| D2 | `POST /api/v1/device/heartbeat` | Bearer | [详情](./device-liveness.md)(无障碍服务存活心跳,心跳也能自注册) |
|
||||||
| D3 | `GET /api/v1/device/liveness` | Bearer | [详情](./device/device-liveness.md)(进 App 查本机是否被判掉线过) |
|
| D3 | `GET /api/v1/device/liveness` | Bearer | [详情](./device-liveness.md)(进 App 查本机是否被判掉线过) |
|
||||||
| D4 | `POST /api/v1/device/liveness/ack` | Bearer | [详情](./device/device-liveness.md)(确认已弹引导,清掉线告警) |
|
| D4 | `POST /api/v1/device/liveness/ack` | Bearer | [详情](./device-liveness.md)(确认已弹引导,清掉线告警) |
|
||||||
| **上报更低价**(前缀 `/api/v1/report`;众包纠偏,人工审核发奖) |||
|
| **上报更低价**(前缀 `/api/v1/report`;众包纠偏,人工审核发奖) |||
|
||||||
| R1 | `POST /api/v1/report` | Bearer | [详情](./other/report-submit.md)(提交上报更低价,multipart:比价记录ID+平台+价格+截图1-4张) |
|
| R1 | `POST /api/v1/report` | Bearer | 提交上报(multipart:`comparison_record_id`/`reported_platform_id`/`reported_price`(元) + 1~4 张截图;原最低价反查 `comparison_record.best_*` 校验须更低)(无单独文档) |
|
||||||
| R2 | `GET /api/v1/report/records` | Bearer | [详情](./other/report-records.md)(上报记录列表,?status=pending/approved/rejected 可选筛选) |
|
| R2 | `GET /api/v1/report/records` | Bearer | 上报记录列表(`?status=` pending/approved/rejected 可选筛选)(无单独文档) |
|
||||||
| **好友邀请**(前缀 `/api/v1/invite`;注册即生效,双方各发 1 万金币) |||
|
| **好友邀请**(前缀 `/api/v1/invite`;注册即生效,双方各发 1 万金币) |||
|
||||||
| I1 | `GET /api/v1/invite/me` | Bearer | [详情](./invite/invite-me.md)(我的邀请码+分享链接+已邀人数/已得金币) |
|
| I1 | `GET /api/v1/invite/me` | Bearer | 我的邀请码 + 分享链接 + 已邀人数/已得金币(无单独文档) |
|
||||||
| I2 | `GET /api/v1/invite/invitees` | Bearer | [详情](./invite/invite-invitees.md)(我邀请的人列表,limit/offset 分页) |
|
| I2 | `GET /api/v1/invite/invitees` | Bearer | 我邀请的人列表(`limit`/`offset` 分页)(无单独文档) |
|
||||||
| I3 | `POST /api/v1/invite/landing-track` | 无 | [详情](./invite/invite-bind.md)(落地页 dl.html 访问上报指纹,剪贴板归因兜底;浏览器无 token) |
|
| I3 | `POST /api/v1/invite/landing-track` | 无 | 落地页 `dl.html` 访问上报指纹(剪贴板归因兜底;浏览器无 token)(无单独文档) |
|
||||||
| I4 | `POST /api/v1/invite/bind` | Bearer | [详情](./invite/invite-bind.md)(绑定邀请人;支持 clipboard/manual 邀请码+fingerprint 指纹反查三种归因) |
|
| I4 | `POST /api/v1/invite/bind` | Bearer | 绑定邀请人;支持 clipboard/manual 邀请码 + fingerprint 指纹反查三种归因(无单独文档) |
|
||||||
| **钱包 / 我的资产**(前缀 `/api/v1/wallet`) |||
|
| **钱包 / 我的资产**(前缀 `/api/v1/wallet`) |||
|
||||||
| 14 | `GET /api/v1/wallet/account` | Bearer | [详情](./wallet/wallet-account.md) |
|
| 14 | `GET /api/v1/wallet/account` | Bearer | [详情](./wallet-account.md) |
|
||||||
| 15 | `GET /api/v1/wallet/coin-transactions` | Bearer | [详情](./wallet/wallet-coin-transactions.md) |
|
| 15 | `GET /api/v1/wallet/coin-transactions` | Bearer | [详情](./wallet-coin-transactions.md) |
|
||||||
| 16 | `GET /api/v1/wallet/cash-transactions` | Bearer | [详情](./wallet/wallet-cash-transactions.md) |
|
| 16 | `GET /api/v1/wallet/cash-transactions` | Bearer | [详情](./wallet-cash-transactions.md) |
|
||||||
| 17 | `GET /api/v1/wallet/exchange-info` | 无 | [详情](./wallet/wallet-exchange-info.md) |
|
| 17 | `GET /api/v1/wallet/exchange-info` | 无 | [详情](./wallet-exchange-info.md) |
|
||||||
| 18 | `POST /api/v1/wallet/exchange` | Bearer | [详情](./wallet/wallet-exchange.md) |
|
| 18 | `POST /api/v1/wallet/exchange` | Bearer | [详情](./wallet-exchange.md) |
|
||||||
| 19 | `POST /api/v1/wallet/bind-wechat` | Bearer | [详情](./wallet/wallet-bind-wechat.md) |
|
| 19 | `POST /api/v1/wallet/bind-wechat` | Bearer | [详情](./wallet-bind-wechat.md) |
|
||||||
| 20 | `POST /api/v1/wallet/unbind-wechat` | Bearer | [详情](./wallet/wallet-unbind-wechat.md) |
|
| 20 | `POST /api/v1/wallet/unbind-wechat` | Bearer | [详情](./wallet-unbind-wechat.md) |
|
||||||
| 21 | `GET /api/v1/wallet/withdraw-info` | Bearer | [详情](./wallet/wallet-withdraw-info.md) |
|
| 21 | `GET /api/v1/wallet/withdraw-info` | Bearer | [详情](./wallet-withdraw-info.md) |
|
||||||
| 22 | `POST /api/v1/wallet/withdraw` | Bearer | [详情](./wallet/wallet-withdraw.md) |
|
| 22 | `POST /api/v1/wallet/withdraw` | Bearer | [详情](./wallet-withdraw.md) |
|
||||||
| 23 | `GET /api/v1/wallet/withdraw/status` | Bearer | [详情](./wallet/wallet-withdraw-status.md) |
|
| 23 | `GET /api/v1/wallet/withdraw/status` | Bearer | [详情](./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-withdraw-orders.md) |
|
||||||
| 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)(关闭免确认到账,解除授权) |
|
|
||||||
| **签到**(前缀 `/api/v1/signin`) |||
|
| **签到**(前缀 `/api/v1/signin`) |||
|
||||||
| 25 | `GET /api/v1/signin/status` | Bearer | [详情](./signin/signin-status.md) |
|
| 25 | `GET /api/v1/signin/status` | Bearer | [详情](./signin-status.md) |
|
||||||
| 26 | `POST /api/v1/signin` | Bearer | [详情](./signin/signin-do.md) |
|
| 26 | `POST /api/v1/signin` | Bearer | [详情](./signin-do.md) |
|
||||||
| 26a | `POST /api/v1/signin/boost` | Bearer | [详情](./signin/signin-boost.md) |
|
| 26a | `POST /api/v1/signin/boost` | Bearer | [详情](./signin-boost.md) |
|
||||||
| **任务**(前缀 `/api/v1/tasks`) |||
|
| **任务**(前缀 `/api/v1/tasks`) |||
|
||||||
| 27 | `GET /api/v1/tasks` | Bearer | [详情](./tasks/tasks-list.md) |
|
| 27 | `GET /api/v1/tasks` | Bearer | [详情](./tasks-list.md) |
|
||||||
| 28 | `POST /api/v1/tasks/{task_key}/claim` | Bearer | [详情](./tasks/tasks-claim.md) |
|
| 28 | `POST /api/v1/tasks/{task_key}/claim` | Bearer | [详情](./tasks-claim.md) |
|
||||||
| **省钱**(前缀 `/api/v1/savings`) |||
|
| **省钱**(前缀 `/api/v1/savings`) |||
|
||||||
| 29 | `GET /api/v1/savings/summary` | Bearer | [详情](./savings/savings-summary.md) |
|
| 29 | `GET /api/v1/savings/summary` | Bearer | [详情](./savings-summary.md) |
|
||||||
| 30 | `GET /api/v1/savings/battle` | Bearer | [详情](./savings/savings-battle.md) |
|
| 30 | `GET /api/v1/savings/battle` | Bearer | [详情](./savings-battle.md) |
|
||||||
| 31 | `GET /api/v1/savings/records` | Bearer | [详情](./savings/savings-records.md) |
|
| 31 | `GET /api/v1/savings/records` | Bearer | [详情](./savings-records.md) |
|
||||||
| **看广告发奖**(前缀 `/api/v1/ad`) |||
|
| **看广告发奖**(前缀 `/api/v1/ad`) |||
|
||||||
| 32 | `GET /api/v1/ad/pangle-callback` | 验签 | [详情](./ad/ad-pangle-callback.md) |
|
| 32 | `GET /api/v1/ad/pangle-callback` | 验签 | [详情](./ad-pangle-callback.md) |
|
||||||
| 33 | `GET /api/v1/ad/reward-status` | Bearer | [详情](./ad/ad-reward-status.md) |
|
| 33 | `GET /api/v1/ad/reward-status` | Bearer | [详情](./ad-reward-status.md) |
|
||||||
| 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad/ad-test-grant.md) |
|
| 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad-test-grant.md) |
|
||||||
| 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad/ad-ecpm-report.md) |
|
| 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad-ecpm-report.md) |
|
||||||
| 35a | `POST /api/v1/ad/feed-reward` | Bearer | [详情](./ad/ad-feed-reward.md) |
|
| 35a | `POST /api/v1/ad/feed-reward` | Bearer | [详情](./ad-feed-reward.md) |
|
||||||
| 35b | `POST /api/v1/ad/reward-noshow` | Bearer | [详情](./ad/ad-reward-noshow.md)(激励视频提前关闭/未发奖留痕,只记原因不发币) |
|
| 35b | `POST /api/v1/ad/reward-noshow` | Bearer | [详情](./ad-reward-noshow.md)(激励视频提前关闭/未发奖留痕,只记原因不发币) |
|
||||||
| 35c | `GET /api/v1/ad/feed-reward/units` | Bearer | [详情](./ad/ad-feed-reward.md)(信息流广告今日已发份数/上限,配合 feed-reward 看进度) |
|
| 35c | `GET /api/v1/ad/feed-reward/units` | Bearer | 信息流广告今日已发份数/上限(配合 `feed-reward` 看进度)(无单独文档) |
|
||||||
| 35d | `POST /api/v1/ad/watch-report` | Bearer | [详情](./ad/ad-watch-report.md)(上报激励视频观看时长,旧客户端兼容) |
|
|
||||||
| **用户资料**(前缀 `/api/v1/user`) |||
|
| **用户资料**(前缀 `/api/v1/user`) |||
|
||||||
| 35 | `PATCH /api/v1/user/profile` | Bearer | [详情](./user/user-profile.md) |
|
| 35 | `PATCH /api/v1/user/profile` | Bearer | [详情](./user-profile.md) |
|
||||||
| 36 | `POST /api/v1/user/avatar` | Bearer | [详情](./user/user-avatar.md) |
|
| 36 | `POST /api/v1/user/avatar` | Bearer | [详情](./user-avatar.md) |
|
||||||
| 36a | `POST /api/v1/user/onboarding/complete` | Bearer | [详情](./user/user-onboarding.md)(标记新手引导完成,按 账号+device_id 幂等,跨卸载重装持久) |
|
| 36a | `POST /api/v1/user/onboarding/complete` | Bearer | 标记新手引导完成(按 账号+device_id 幂等,跨卸载重装持久)(无单独文档) |
|
||||||
| 36b | `GET /api/v1/user/onboarding/status` | Bearer | [详情](./user/user-onboarding.md)(查该 账号+设备 是否走过引导,运营在 admin 删记录即触发重走) |
|
| 36b | `GET /api/v1/user/onboarding/status` | Bearer | 查该 (账号,设备) 是否走过引导(运营在 admin 删记录即触发重走)(无单独文档) |
|
||||||
| 37 | `DELETE /api/v1/user` | Bearer | [详情](./user/user-delete.md) |
|
| 37 | `DELETE /api/v1/user` | Bearer | [详情](./user-delete.md) |
|
||||||
| **帮助与反馈**(前缀 `/api/v1/feedback`) |||
|
| **帮助与反馈**(前缀 `/api/v1/feedback`) |||
|
||||||
| 38 | `POST /api/v1/feedback` | Bearer | [详情](./other/feedback.md) |
|
| 38 | `POST /api/v1/feedback` | Bearer | [详情](./feedback.md) |
|
||||||
| 38a | `GET /api/v1/feedback/config` | Bearer | [详情](./other/feedback-config.md)(反馈页「加群二维码」卡配置:开关+二维码图+三行文案) |
|
| 38a | `GET /api/v1/feedback/config` | Bearer | 反馈页「加群二维码」卡配置(开关 + 二维码图 + 三行文案)(无单独文档) |
|
||||||
| 38b | `GET /api/v1/feedback/records` | Bearer | [详情](./other/feedback-records.md)(我的反馈历史,pending/adopted/rejected) |
|
| 38b | `GET /api/v1/feedback/records` | Bearer | 我的反馈历史(pending/adopted/rejected)(无单独文档) |
|
||||||
| **埋点 & 订单上报**(前缀分散;全部 Bearer 除 analytics/events 不强制登录) |||
|
|
||||||
| E1 | `POST /api/v1/analytics/events` | 无 | [详情](./other/analytics-events.md)(批量上报埋点事件,不强制登录,每批最多200条) |
|
|
||||||
| E2 | `POST /api/v1/order/report` | Bearer | [详情](./other/order-report.md)(上报归因订单,比价后5分钟内点链接+支付金额与比价价相差≤1元) |
|
|
||||||
| **首页门面数据 / 客户端配置**(前缀 `/api/v1/platform`;全平台展示数字 + 运营开关,**全部不鉴权**,登录前可读) |||
|
| **首页门面数据 / 客户端配置**(前缀 `/api/v1/platform`;全平台展示数字 + 运营开关,**全部不鉴权**,登录前可读) |||
|
||||||
| 39 | `GET /api/v1/platform/stats` | 无 | [详情](./platform/platform-stats.md) |
|
| 39 | `GET /api/v1/platform/stats` | 无 | [详情](./platform-stats.md) |
|
||||||
| 40 | `GET /api/v1/platform/savings-feed` | 无 | [详情](./savings/platform-savings-feed.md) |
|
| 40 | `GET /api/v1/platform/savings-feed` | 无 | [详情](./platform-savings-feed.md) |
|
||||||
| 40a | `GET /api/v1/platform/flags` | 无 | [详情](./platform/platform-flags.md)(客户端运营 feature flag,比价/领券期广告开关等,拉取后缓存) |
|
| 40a | `GET /api/v1/platform/flags` | 无 | 客户端运营 feature flag(比价/领券期广告开关等),拉取后缓存(无单独文档) |
|
||||||
| 40b | `GET /api/v1/platform/ad-config` | 无 | [详情](./platform/platform-ad-config.md)(客户端拉广告配置:穿山甲 app_id+各位ID+各场景开关;不含验签密钥) |
|
| 40b | `GET /api/v1/platform/ad-config` | 无 | 客户端拉广告配置(穿山甲 app_id + 各位 ID + 各场景开关;不含验签密钥)(无单独文档) |
|
||||||
| 40c | `GET /api/v1/platform/app-version` | 无 | [详情](./platform/platform-app-version.md)(最新 App 版本,OTA 检查更新;与本机 versionCode 比) |
|
| 40c | `GET /api/v1/platform/app-version` | 无 | 最新 App 版本(OTA 检查更新;与本机 versionCode 比)(无单独文档) |
|
||||||
| **微信支付回调**(前缀 `/api/v1/wxpay`) |||
|
| **微信支付回调**(前缀 `/api/v1/wxpay`) |||
|
||||||
| W1 | `POST /api/v1/wxpay/transfer-auth-notify` | 无 | 免确认收款授权结果通知(一期 stub:仅应答 200 不验签不改账,授权状态靠主动查询兜底)(无单独文档) |
|
| W1 | `POST /api/v1/wxpay/transfer-auth-notify` | 无 | 免确认收款授权结果通知(一期 stub:仅应答 200 不验签不改账,授权状态靠主动查询兜底)(无单独文档) |
|
||||||
| **CPS 群发短链落地**(**无前缀**,挂域名根;公网不鉴权) |||
|
| **CPS 群发短链落地**(**无前缀**,挂域名根;公网不鉴权) |||
|
||||||
| C1 | `GET /c/{code}` | 无 | [详情](./other/cps-redirect.md)(短链落地:微信授权拿 openid + 记点击 + 302 跳/淘宝 H5 落地页) |
|
| C1 | `GET /c/{code}` | 无 | [详情](./cps-redirect.md)(短链落地:微信授权拿 openid + 记点击 + 302 跳/淘宝 H5 落地页) |
|
||||||
| C2 | `POST /c/{code}/copy` | 无 | [详情](./other/cps-redirect.md)(淘宝落地页点「复制口令」记 `copy`) |
|
| C2 | `POST /c/{code}/copy` | 无 | [详情](./cps-redirect.md)(淘宝落地页点「复制口令」记 `copy`) |
|
||||||
| C3 | `GET /wx/oauth/cb` | 无 | [详情](./other/cps-redirect.md)(微信网页授权回调;upsert `cps_wx_user` + 种 cookie,`include_in_schema=False`) |
|
| C3 | `GET /wx/oauth/cb` | 无 | [详情](./cps-redirect.md)(微信网页授权回调;upsert `cps_wx_user` + 种 cookie,`include_in_schema=False`) |
|
||||||
| C4 | `GET /MP_verify_*.txt` | 无 | [详情](./other/cps-redirect.md)(微信「网页授权域名」归属校验文件,`include_in_schema=False`) |
|
| C4 | `GET /MP_verify_*.txt` | 无 | [详情](./cps-redirect.md)(微信「网页授权域名」归属校验文件,`include_in_schema=False`) |
|
||||||
| **内部回写端点**(前缀 `/internal`;pricebot/发布流程→app-server,**`X-Internal-Secret` 头**,非客户端接口) |||
|
| **内部回写端点**(前缀 `/internal`;pricebot/发布流程→app-server,**`X-Internal-Secret` 头**,非客户端接口) |||
|
||||||
| N1 | `POST /internal/price-observation` | 内部密钥 | [详情](./internal/internal.md)(比价价格事实批量落 `price_observation`) |
|
| N1 | `POST /internal/price-observation` | 内部密钥 | [详情](./internal.md)(比价价格事实批量落 `price_observation`) |
|
||||||
| N2 | `GET /internal/store-mapping/lookup` | 内部密钥 | [详情](./internal/internal.md)(按源平台店名反查目标平台已沉淀店铺 id/deeplink) |
|
| N2 | `GET /internal/store-mapping/lookup` | 内部密钥 | [详情](./internal.md)(按源平台店名反查目标平台已沉淀店铺 id/deeplink) |
|
||||||
| N3 | `POST /internal/store-mapping` | 内部密钥 | [详情](./internal/internal.md)(跨平台店铺身份映射落 `store_mapping`) |
|
| N3 | `POST /internal/store-mapping` | 内部密钥 | [详情](./internal.md)(跨平台店铺身份映射落 `store_mapping`) |
|
||||||
| N4 | `POST /internal/store-mapping/invalidate` | 内部密钥 | [详情](./internal/internal.md)(标记某平台 shopId 缓存 deeplink 失效) |
|
| N4 | `POST /internal/store-mapping/invalidate` | 内部密钥 | [详情](./internal.md)(标记某平台 shopId 缓存 deeplink 失效) |
|
||||||
| N5 | `POST /internal/launch-confirm-sample` | 内部密钥 | [详情](./internal/internal.md)(启动确认窗兜底样本落 `launch_confirm_sample`) |
|
| N5 | `POST /internal/launch-confirm-sample` | 内部密钥 | [详情](./internal.md)(启动确认窗兜底样本落 `launch_confirm_sample`) |
|
||||||
| N6 | `POST /internal/app-version` | 内部密钥 | [详情](./internal/internal.md)(发布流程写最新 App 版本,落 `app_config`) |
|
| N6 | `POST /internal/app-version` | 内部密钥 | [详情](./internal.md)(发布流程写最新 App 版本,落 `app_config`) |
|
||||||
| **静态资源**(StaticFiles 挂载,见下方 `/media` 静态服务) |||
|
| **静态资源**(StaticFiles 挂载,见下方 `/media` 静态服务) |||
|
||||||
| - | `GET /media/avatars/<file>` | 无 | 用户头像;返回二进制图片 |
|
| - | `GET /media/avatars/<file>` | 无 | 用户头像;返回二进制图片 |
|
||||||
| - | `GET /media/feedback/<file>` | 无 | 反馈截图;返回二进制图片 |
|
| - | `GET /media/feedback/<file>` | 无 | 反馈截图;返回二进制图片 |
|
||||||
| **运营后台 Admin**(独立子应用 `app/admin/`,前缀 `/admin/api`,独立进程 + 独立 admin JWT。鉴权列:`admin`=任意已登录管理员,`operator`/`finance`/`super_admin`=需对应角色(`super_admin` 恒通过)) |||
|
| **运营后台 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) |
|
| A1 | `POST /admin/api/auth/login` | 无 | [详情](./admin-auth-login.md) |
|
||||||
| A2 | `GET /admin/api/auth/me` | admin | [详情](./admin/auth/admin-auth-me.md) |
|
| A2 | `GET /admin/api/auth/me` | admin | [详情](./admin-auth-me.md) |
|
||||||
| A3 | `GET /admin/api/stats/overview` | admin | [详情](./admin/admin-stats-overview.md) |
|
| A3 | `GET /admin/api/stats/overview` | admin | [详情](./admin-stats-overview.md) |
|
||||||
| A4 | `GET /admin/api/users` | admin | [详情](./admin/users/admin-users-list.md) |
|
| A4 | `GET /admin/api/users` | admin | [详情](./admin-users-list.md) |
|
||||||
| A5 | `GET /admin/api/users/{user_id}` | admin | [详情](./admin/users/admin-user-detail.md) |
|
| A5 | `GET /admin/api/users/{user_id}` | admin | [详情](./admin-user-detail.md) |
|
||||||
| A6 | `POST /admin/api/users/{user_id}/status` | operator | [详情](./admin/users/admin-user-status.md) |
|
| A6 | `POST /admin/api/users/{user_id}/status` | operator | [详情](./admin-user-status.md) |
|
||||||
| A7 | `POST /admin/api/users/{user_id}/coins` | finance | [详情](./admin/users/admin-user-coins.md) |
|
| A7 | `POST /admin/api/users/{user_id}/coins` | finance | [详情](./admin-user-coins.md) |
|
||||||
| A8 | `POST /admin/api/users/{user_id}/cash` | finance | [详情](./admin/users/admin-user-cash.md) |
|
| A8 | `POST /admin/api/users/{user_id}/cash` | finance | [详情](./admin-user-cash.md) |
|
||||||
| A9 | `GET /admin/api/wallet/coin-transactions` | admin | [详情](./admin/wallet/admin-wallet-coin-transactions.md) |
|
| A9 | `GET /admin/api/wallet/coin-transactions` | admin | [详情](./admin-wallet-coin-transactions.md) |
|
||||||
| A10 | `GET /admin/api/wallet/cash-transactions` | admin | [详情](./admin/wallet/admin-wallet-cash-transactions.md) |
|
| A10 | `GET /admin/api/wallet/cash-transactions` | admin | [详情](./admin-wallet-cash-transactions.md) |
|
||||||
| A11 | `GET /admin/api/withdraws` | admin | [详情](./admin/withdraws/admin-withdraws-list.md) |
|
| A11 | `GET /admin/api/withdraws` | admin | [详情](./admin-withdraws-list.md) |
|
||||||
| A12 | `POST /admin/api/withdraws/reconcile` | finance | [详情](./admin/withdraws/admin-withdraw-reconcile.md) |
|
| A12 | `POST /admin/api/withdraws/reconcile` | finance | [详情](./admin-withdraw-reconcile.md) |
|
||||||
| A13 | `POST /admin/api/withdraws/{out_bill_no}/refresh` | finance | [详情](./admin/withdraws/admin-withdraw-refresh.md) |
|
| A13 | `POST /admin/api/withdraws/{out_bill_no}/refresh` | finance | [详情](./admin-withdraw-refresh.md) |
|
||||||
| A14 | `GET /admin/api/feedbacks` | admin | [详情](./admin/feedbacks/admin-feedbacks-list.md) |
|
| A14 | `GET /admin/api/feedbacks` | admin | [详情](./admin-feedbacks-list.md) |
|
||||||
| A15 | `POST /admin/api/feedbacks/{feedback_id}/handle` | operator | [详情](./admin/feedbacks/admin-feedback-handle.md) |
|
| A15 | `POST /admin/api/feedbacks/{feedback_id}/handle` | operator | [详情](./admin-feedback-handle.md) |
|
||||||
| A16 | `GET /admin/api/admins` | super_admin | [详情](./admin/admins/admin-admins-list.md) |
|
| A16 | `GET /admin/api/admins` | super_admin | [详情](./admin-admins-list.md) |
|
||||||
| A17 | `POST /admin/api/admins` | super_admin | [详情](./admin/admins/admin-admin-create.md) |
|
| A17 | `POST /admin/api/admins` | super_admin | [详情](./admin-admin-create.md) |
|
||||||
| A18 | `PATCH /admin/api/admins/{admin_id}` | super_admin | [详情](./admin/admins/admin-admin-update.md) |
|
| A18 | `PATCH /admin/api/admins/{admin_id}` | super_admin | [详情](./admin-admin-update.md) |
|
||||||
| A19 | `GET /admin/api/audit-logs` | admin | [详情](./admin/admin-audit-logs.md) |
|
| A19 | `GET /admin/api/audit-logs` | admin | [详情](./admin-audit-logs.md) |
|
||||||
| A20 | `GET /admin/api/dashboard-display` | admin | [详情](./admin/admin-dashboard-display.md) |
|
| A20 | `GET /admin/api/dashboard-display` | admin | [详情](./admin-dashboard-display.md) |
|
||||||
| A21 | `PATCH /admin/api/dashboard-display/{metric}` | operator | [详情](./admin/admin-dashboard-display.md) |
|
| A21 | `PATCH /admin/api/dashboard-display/{metric}` | operator | [详情](./admin-dashboard-display.md) |
|
||||||
| A22 | `GET /admin/api/marquee-seeds` | admin | [详情](./admin/admin-marquee-seeds.md) |
|
| A22 | `GET /admin/api/marquee-seeds` | admin | [详情](./admin-marquee-seeds.md) |
|
||||||
| A23 | `POST /admin/api/marquee-seeds` | operator | [详情](./admin/admin-marquee-seeds.md) |
|
| A23 | `POST /admin/api/marquee-seeds` | operator | [详情](./admin-marquee-seeds.md) |
|
||||||
| A24 | `PATCH /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin/admin-marquee-seeds.md) |
|
| A24 | `PATCH /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) |
|
||||||
| A25 | `DELETE /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin/admin-marquee-seeds.md) |
|
| A25 | `DELETE /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) |
|
||||||
| A26 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin/admin-marquee-seeds.md) |
|
| A26 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin-marquee-seeds.md) |
|
||||||
| A27 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./admin/admin-marquee-seeds.md) |
|
| A27 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./admin-marquee-seeds.md) |
|
||||||
| A28 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin/ad/admin-ad-coin-audit.md)(看广告金币公式复算对账,只读) |
|
| A28 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin-ad-coin-audit.md)(看广告金币公式复算对账,只读) |
|
||||||
| A29 | `GET /admin/api/ad-revenue-report` | admin | [详情](./admin/ad/admin-ad-revenue-report.md)(广告收益报表:按用户/日期/类型/应用/代码位 聚合 条数/收益/金币,只读) |
|
| A29 | `GET /admin/api/ad-revenue-report` | admin | [详情](./admin-ad-revenue-report.md)(广告收益报表:按用户/日期/类型/应用/代码位 聚合 条数/收益/金币,只读) |
|
||||||
| - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) |
|
| - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) |
|
||||||
|
|
||||||
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
|
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
|
||||||
@@ -209,8 +201,8 @@
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `id` | int | 用户主键 |
|
| `id` | int | 用户主键 |
|
||||||
| `phone` | string | 手机号(注销账号后变 `deleted_<id>` 占位释放唯一约束) |
|
| `phone` | string | 手机号(注销账号后变 `deleted_<id>` 占位释放唯一约束) |
|
||||||
| `nickname` | string \| null | 昵称,经 [`PATCH /api/v1/user/profile`](./user/user-profile.md) 修改 |
|
| `nickname` | string \| null | 昵称,经 [`PATCH /api/v1/user/profile`](./user-profile.md) 修改 |
|
||||||
| `avatar_url` | string \| null | 头像相对 URL(`/media/avatars/...`),经 [`POST /api/v1/user/avatar`](./user/user-avatar.md) 上传 |
|
| `avatar_url` | string \| null | 头像相对 URL(`/media/avatars/...`),经 [`POST /api/v1/user/avatar`](./user-avatar.md) 上传 |
|
||||||
| `register_channel` | string | 注册渠道:`jverify` / `sms` |
|
| `register_channel` | string | 注册渠道:`jverify` / `sms` |
|
||||||
| `status` | string | `active` / `disabled` / `deleted` |
|
| `status` | string | `active` / `disabled` / `deleted` |
|
||||||
| `created_at` | datetime | 注册时间 |
|
| `created_at` | datetime | 注册时间 |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# POST /api/v1/ad/ecpm-report — 上报本次广告展示的 eCPM(内部收益统计)
|
# POST /api/v1/ad/ecpm-report — 上报本次广告展示的 eCPM(内部收益统计)
|
||||||
|
|
||||||
> 所属:Ad 组(前缀 `/api/v1/ad`) | 鉴权:Bearer | [← 返回 API 索引](../README.md)
|
> 所属:Ad 组(前缀 `/api/v1/ad`) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||||
|
|
||||||
## 入参
|
## 入参
|
||||||
请求体:`EcpmReportIn`
|
请求体:`EcpmReportIn`
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# GET /api/v1/ad/pangle-callback — 穿山甲 GroMore 激励视频发奖回调(S2S)
|
# GET /api/v1/ad/pangle-callback — 穿山甲 GroMore 激励视频发奖回调(S2S)
|
||||||
|
|
||||||
> 所属:Ad 组(前缀 `/api/v1/ad`) | 鉴权:**无 JWT,靠验签**(穿山甲 GroMore 服务器调用) | 限流:同 IP ≤300 次/分 | [← 返回 API 索引](../README.md)
|
> 所属:Ad 组(前缀 `/api/v1/ad`) | 鉴权:**无 JWT,靠验签**(穿山甲 GroMore 服务器调用) | 限流:同 IP ≤300 次/分 | [← 返回 API 索引](./README.md)
|
||||||
>
|
>
|
||||||
> ⚠️ 我们客户端用 `useMediation(true)`(GroMore 融合),回调走 **GroMore 广告位层级**(规范见 supportcenter/26240),**不是**联盟代码位层级(5416)。两者密钥、响应格式都不同,别混。后台配置入口:**GroMore 聚合管理 → 搜广告位ID → 编辑 → 勾选「服务端激励回调」**(广告位层级配了就别再在代码位层级重复配,会冲突)。
|
> ⚠️ 我们客户端用 `useMediation(true)`(GroMore 融合),回调走 **GroMore 广告位层级**(规范见 supportcenter/26240),**不是**联盟代码位层级(5416)。两者密钥、响应格式都不同,别混。后台配置入口:**GroMore 聚合管理 → 搜广告位ID → 编辑 → 勾选「服务端激励回调」**(广告位层级配了就别再在代码位层级重复配,会冲突)。
|
||||||
>
|
>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# GET /api/v1/ad/reward-status — 今日看广告发奖进度
|
# GET /api/v1/ad/reward-status — 今日看广告发奖进度
|
||||||
|
|
||||||
> 所属:Ad 组(前缀 `/api/v1/ad`) | 鉴权:Bearer | [← 返回 API 索引](../README.md)
|
> 所属:Ad 组(前缀 `/api/v1/ad`) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||||
|
|
||||||
## 入参
|
## 入参
|
||||||
无(用户由 token 确定)。
|
无(用户由 token 确定)。
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# POST /api/v1/ad/test-grant — [仅本地联调]模拟穿山甲回调发奖
|
# POST /api/v1/ad/test-grant — [仅本地联调]模拟穿山甲回调发奖
|
||||||
|
|
||||||
> 所属:Ad 组(前缀 `/api/v1/ad`) | 鉴权:Bearer | 限流:同 IP ≤60 次/分 | [← 返回 API 索引](../README.md)
|
> 所属:Ad 组(前缀 `/api/v1/ad`) | 鉴权:Bearer | 限流:同 IP ≤60 次/分 | [← 返回 API 索引](./README.md)
|
||||||
>
|
>
|
||||||
> ⚠️ **仅本地联调**,受 `AD_REWARD_TEST_GRANT_ENABLED` 开关控制,**生产必须关闭**(默认 False → 一律 404)。
|
> ⚠️ **仅本地联调**,受 `AD_REWARD_TEST_GRANT_ENABLED` 开关控制,**生产必须关闭**(默认 False → 一律 404)。
|
||||||
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# POST /api/v1/ad/watch-report — 上报激励视频观看时长
|
|
||||||
|
|
||||||
> 所属:Ad 组(前缀 `/api/v1/ad`) | 鉴权:Bearer | 限流:同 IP ≤120 次/分 | [← 返回 API 索引](../README.md)
|
|
||||||
|
|
||||||
客户端在激励视频关闭(onAdClose)后上报本次实际观看秒数,服务端累计到当日总时长。当前产品只保留每日 500 次上限,DAILY_AD_WATCH_SECONDS_LIMIT=0 表示时长闸不启用;该接口仍保留用于旧客户端兼容和排查观看时长。
|
|
||||||
|
|
||||||
## 入参
|
|
||||||
|
|
||||||
| 字段 | 类型 | 必填 | 说明 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `seconds` | int | ✅ (≥0) | 本次观看秒数,服务端夹 [0, MAX_SINGLE_WATCH_SECONDS] |
|
|
||||||
|
|
||||||
Mock 入参:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"seconds": 28
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 出参
|
|
||||||
|
|
||||||
响应 `200`:`WatchReportOut`
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| `watched_seconds_today` | int | 今日累计观看秒数 |
|
|
||||||
| `watch_seconds_limit` | int | 每日上限(秒);0 表示当前未启用时长闸 |
|
|
||||||
| `watch_seconds_remaining` | int | 今日剩余可观看秒数 |
|
|
||||||
|
|
||||||
Mock 出参:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"watched_seconds_today": 128,
|
|
||||||
"watch_seconds_limit": 0,
|
|
||||||
"watch_seconds_remaining": 0
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 错误码
|
|
||||||
- `401` 未鉴权 / token 失效
|
|
||||||
- `422` `seconds` 缺或为负
|
|
||||||
|
|
||||||
## 说明
|
|
||||||
- 鉴权靠 Bearer,`user_id` 取自 JWT(不信 body)
|
|
||||||
- 后端只做累计 + 上限裁切,不据此发奖(发奖靠 S2S 回调)
|
|
||||||
- `watch_seconds_limit=0` 时客户端不据此拦截,按每日 500 次上限走
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Admin 看广告金币审计
|
# Admin 看广告金币审计
|
||||||
|
|
||||||
> 所属:Admin 组(前缀 `/admin/api/ad-coin-audit`) | 鉴权:Admin Bearer(任意已登录 admin,只读) | [← 返回 API 索引](../../README.md)
|
> 所属:Admin 组(前缀 `/admin/api/ad-coin-audit`) | 鉴权:Admin Bearer(任意已登录 admin,只读) | [← 返回 API 索引](./README.md)
|
||||||
|
|
||||||
把「看视频赚金币」(`ad_reward_record`)和「比价信息流广告」(`ad_feed_reward_record`)两类发奖记录,用与**正式发奖完全相同**的公式 [`app/core/rewards.py` `calculate_ad_reward_coin`](../../app/core/rewards.py) 复算一遍 `expected_coin`,与实际入账的 `actual_coin` 对比,核对金币公式是否生效。**纯只读对账**,不发币、不改任何数据。
|
把「看视频赚金币」(`ad_reward_record`)和「比价信息流广告」(`ad_feed_reward_record`)两类发奖记录,用与**正式发奖完全相同**的公式 [`app/core/rewards.py` `calculate_ad_reward_coin`](../../app/core/rewards.py) 复算一遍 `expected_coin`,与实际入账的 `actual_coin` 对比,核对金币公式是否生效。**纯只读对账**,不发币、不改任何数据。
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Admin 广告收益报表
|
# Admin 广告收益报表
|
||||||
|
|
||||||
> 所属:Admin 组(前缀 `/admin/api/ad-revenue-report`) | 鉴权:Admin Bearer(任意已登录 admin,只读) | [← 返回 API 索引](../../README.md)
|
> 所属:Admin 组(前缀 `/admin/api/ad-revenue-report`) | 鉴权:Admin Bearer(任意已登录 admin,只读) | [← 返回 API 索引](./README.md)
|
||||||
|
|
||||||
按 **用户 × 日期 × 广告类型 × 我们的应用 × 我们的代码位** 聚合,回答「每个用户某天、每类广告(激励视频 / 信息流 / 历史 Draw)分别**看了多少条**、**收益多少**、按现算法**发了多少金币**、广告来自**哪个应用的哪个代码位**」。**纯只读**,不发币、不改数据,也**不改发奖逻辑**。
|
按 **用户 × 日期 × 广告类型 × 我们的应用 × 我们的代码位** 聚合,回答「每个用户某天、每类广告(激励视频 / 信息流 / 历史 Draw)分别**看了多少条**、**收益多少**、按现算法**发了多少金币**、广告来自**哪个应用的哪个代码位**」。**纯只读**,不发币、不改数据,也**不改发奖逻辑**。
|
||||||
|
|
||||||
@@ -46,10 +46,7 @@
|
|||||||
| `total` | int | 当前筛选下的**分页总条数**(全量,不受分页影响;= 前端分页器 total) |
|
| `total` | int | 当前筛选下的**分页总条数**(全量,不受分页影响;= 前端分页器 total) |
|
||||||
| `truncated` | bool | 当前页之后是否还有更多事件(`len(events) > offset + limit`) |
|
| `truncated` | bool | 当前页之后是否还有更多事件(`len(events) > offset + limit`) |
|
||||||
| `total_impressions` | int | 全量展示条数合计 |
|
| `total_impressions` | int | 全量展示条数合计 |
|
||||||
| `total_revenue_yuan` | float | 全量**客户端预估**收益合计(元;eCPM 折算) |
|
| `total_revenue_yuan` | float | 全量收益合计(元) |
|
||||||
| `total_pangle_revenue_yuan` | float \| null | 全量**穿山甲后台预估**收益合计(元;GroMore `revenue`)。穿山甲无用户/类型/场景维度,**仅全量视图**(未按 `user_id`/`ad_type`/`feed_scene` 过滤)有值,否则 `null` |
|
|
||||||
| `total_pangle_api_revenue_yuan` | float \| null | 全量**穿山甲收益Api**合计(元;GroMore `api_revenue`,各 ADN 回传更接近结算);未配 Reporting / 查当天 / 非全量视图为 `null` |
|
|
||||||
| `pangle_revenue_available` | bool | 本次是否带穿山甲后台收益(=全量视图且对应日期已同步到 `ad_pangle_daily_revenue`) |
|
|
||||||
| `total_expected_coin` | int | 全量应发金币合计 |
|
| `total_expected_coin` | int | 全量应发金币合计 |
|
||||||
| `total_actual_coin` | int | 全量实发金币合计 |
|
| `total_actual_coin` | int | 全量实发金币合计 |
|
||||||
| `mismatch_count` | int | 应发≠实发的组数(=0 说明全部按公式发放) |
|
| `mismatch_count` | int | 应发≠实发的组数(=0 说明全部按公式发放) |
|
||||||
@@ -60,9 +57,7 @@
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `date` | string | 北京时间 `YYYY-MM-DD` |
|
| `date` | string | 北京时间 `YYYY-MM-DD` |
|
||||||
| `impressions` | int | 当天展示条数合计 |
|
| `impressions` | int | 当天展示条数合计 |
|
||||||
| `revenue_yuan` | float | 当天客户端预估收益合计(元;eCPM 折算) |
|
| `revenue_yuan` | float | 当天预估收益合计(元) |
|
||||||
| `pangle_revenue_yuan` | float \| null | 当天穿山甲后台预估收益(元;GroMore `revenue`);非全量视图 / 无数据为 `null` |
|
|
||||||
| `pangle_api_revenue_yuan` | float \| null | 当天穿山甲收益Api(元;GroMore `api_revenue`);未配 / 当天 / 无数据为 `null` |
|
|
||||||
| `expected_coin` | int | 当天应发金币合计 |
|
| `expected_coin` | int | 当天应发金币合计 |
|
||||||
| `actual_coin` | int | 当天实发金币合计 |
|
| `actual_coin` | int | 当天实发金币合计 |
|
||||||
|
|
||||||
@@ -130,6 +125,5 @@
|
|||||||
- **展示 vs 发奖分离**:信息流轮播一会话可展示多条(都计入 `impressions`),但发奖仍按现规则(一会话发一次),`coin` 不因展示条数变化——这是有意设计(用户中途关只记展示不发奖)。
|
- **展示 vs 发奖分离**:信息流轮播一会话可展示多条(都计入 `impressions`),但发奖仍按现规则(一会话发一次),`coin` 不因展示条数变化——这是有意设计(用户中途关只记展示不发奖)。
|
||||||
- **历史 Draw 不可拆**:迁移(Draw→普通信息流)前,Draw 发奖混在 `ad_feed_reward_record` 且无类型标记,金币侧统一记 `feed`;迁移后 Draw 不再产生新数据。展示侧 `ad_type` 由客户端上报区分,故 `draw` 桶基本为空。
|
- **历史 Draw 不可拆**:迁移(Draw→普通信息流)前,Draw 发奖混在 `ad_feed_reward_record` 且无类型标记,金币侧统一记 `feed`;迁移后 Draw 不再产生新数据。展示侧 `ad_type` 由客户端上报区分,故 `draw` 桶基本为空。
|
||||||
- **来源字段从上线起齐全**:`app_env`/`our_code_id` 是本期新增列,历史记录为 NULL(报表来源列留空)。
|
- **来源字段从上线起齐全**:`app_env`/`our_code_id` 是本期新增列,历史记录为 NULL(报表来源列留空)。
|
||||||
- **逐条/明细的收益是预估**:`items[].revenue_yuan` 基于客户端上报的 eCPM 折算,非穿山甲后台结算值。
|
- **收益是预估**:基于客户端上报的 eCPM,非穿山甲后台结算值;以后台报表为结算权威。
|
||||||
- **穿山甲后台收益(汇总/趋势级)**:`total_pangle_revenue_yuan`(预估 `revenue`)与 `total_pangle_api_revenue_yuan`(收益Api `api_revenue`,更接近结算)来自穿山甲 **GroMore 数据 API**(`integrations/pangle_report` + `scripts/sync_pangle_revenue` 按天 T+1 拉取入 [ad_pangle_daily_revenue](../database/ad_pangle_daily_revenue.md))。穿山甲**不提供分用户/设备/类型/场景维度**(官方明确),最细到 日期×应用×代码位,故只用于汇总与按天趋势的对照,**不挂到逐条事件行**;且仅在全量视图(未按 user/类型/场景过滤)展示。配置见 `.env` 的 `PANGLE_REPORT_*`。
|
|
||||||
- **对账聚合级 + 逐条下钻**:行级 `matched` 给出该组(用户×类型×应用×代码位)应发是否==实发;**展开 `records` 即可看该组逐条明细**(eCPM/因子1/份数/LT/因子2/应发/实发/一致)定位到具体记录。独立逐条审计接口 [admin-ad-coin-audit](./admin-ad-coin-audit.md) 仍保留(同一复算口径,可全局按场景/只看不符筛选)。
|
- **对账聚合级 + 逐条下钻**:行级 `matched` 给出该组(用户×类型×应用×代码位)应发是否==实发;**展开 `records` 即可看该组逐条明细**(eCPM/因子1/份数/LT/因子2/应发/实发/一致)定位到具体记录。独立逐条审计接口 [admin-ad-coin-audit](./admin-ad-coin-audit.md) 仍保留(同一复算口径,可全局按场景/只看不符筛选)。
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# POST /admin/api/admins — 创建管理员
|
# POST /admin/api/admins — 创建管理员
|
||||||
|
|
||||||
> 所属:Admin·Accounts 组(前缀 `/admin/api/admins`) | 鉴权:Bearer admin_token(角色:super_admin) | [← 返回 API 索引](../../README.md)
|
> 所属:Admin·Accounts 组(前缀 `/admin/api/admins`) | 鉴权:Bearer admin_token(角色:super_admin) | [← 返回 API 索引](./README.md)
|
||||||
|
|
||||||
## 入参
|
## 入参
|
||||||
**application/json**:
|
**application/json**:
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# PATCH /admin/api/admins/{admin_id} — 改角色/启停/重置密码
|
# PATCH /admin/api/admins/{admin_id} — 改角色/启停/重置密码
|
||||||
|
|
||||||
> 所属:Admin·Accounts 组(前缀 `/admin/api/admins`) | 鉴权:Bearer admin_token(角色:super_admin) | [← 返回 API 索引](../../README.md)
|
> 所属:Admin·Accounts 组(前缀 `/admin/api/admins`) | 鉴权:Bearer admin_token(角色:super_admin) | [← 返回 API 索引](./README.md)
|
||||||
|
|
||||||
## 入参
|
## 入参
|
||||||
**路径参数**:
|
**路径参数**:
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# GET /admin/api/admins — 管理员列表
|
# GET /admin/api/admins — 管理员列表
|
||||||
|
|
||||||
> 所属:Admin·Accounts 组(前缀 `/admin/api/admins`) | 鉴权:Bearer admin_token(角色:super_admin) | [← 返回 API 索引](../../README.md)
|
> 所属:Admin·Accounts 组(前缀 `/admin/api/admins`) | 鉴权:Bearer admin_token(角色:super_admin) | [← 返回 API 索引](./README.md)
|
||||||
|
|
||||||
## 入参
|
## 入参
|
||||||
无(按 `id` 升序返回全部,无分页)
|
无(按 `id` 升序返回全部,无分页)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# GET /admin/api/audit-logs — 审计日志(谁改了什么,游标分页)
|
# GET /admin/api/audit-logs — 审计日志(谁改了什么,游标分页)
|
||||||
|
|
||||||
> 所属:Admin·Audit 组(前缀 `/admin/api/audit-logs`) | 鉴权:Bearer admin_token(角色:任意已登录 admin) | [← 返回 API 索引](../README.md)
|
> 所属:Admin·Audit 组(前缀 `/admin/api/audit-logs`) | 鉴权:Bearer admin_token(角色:任意已登录 admin) | [← 返回 API 索引](./README.md)
|
||||||
|
|
||||||
## 入参(query)
|
## 入参(query)
|
||||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# POST /admin/api/auth/login — 管理员登录
|
# POST /admin/api/auth/login — 管理员登录
|
||||||
|
|
||||||
> 所属:Admin·Auth 组(前缀 `/admin/api/auth`) | 鉴权:无 | [← 返回 API 索引](../../README.md)
|
> 所属:Admin·Auth 组(前缀 `/admin/api/auth`) | 鉴权:无 | [← 返回 API 索引](./README.md)
|
||||||
|
|
||||||
## 入参
|
## 入参
|
||||||
**application/json**:
|
**application/json**:
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# GET /admin/api/auth/me — 当前管理员
|
# GET /admin/api/auth/me — 当前管理员
|
||||||
|
|
||||||
> 所属:Admin·Auth 组(前缀 `/admin/api/auth`) | 鉴权:Bearer admin_token(角色:任意已登录 admin) | [← 返回 API 索引](../../README.md)
|
> 所属:Admin·Auth 组(前缀 `/admin/api/auth`) | 鉴权:Bearer admin_token(角色:任意已登录 admin) | [← 返回 API 索引](./README.md)
|
||||||
|
|
||||||
## 入参
|
## 入参
|
||||||
无(身份取自 Header token)
|
无(身份取自 Header token)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Admin 首页数据配置 — 三统计展示模式
|
# Admin 首页数据配置 — 三统计展示模式
|
||||||
|
|
||||||
> 所属:Admin 组(前缀 `/admin/api/dashboard-display`) | 鉴权:Admin Bearer(改需 operator/super) | [← 返回 API 索引](../README.md)
|
> 所属:Admin 组(前缀 `/admin/api/dashboard-display`) | 鉴权:Admin Bearer(改需 operator/super) | [← 返回 API 索引](./README.md)
|
||||||
|
|
||||||
配置客户端首页三个门面数字(帮助用户 / 完成比价 / 累计节省)的展示模式。每个指标可独立选 real/manual/random。用户侧读取见 [platform-stats](./platform-stats.md);表见 [ops_stat_config](../database/ops_stat_config.md)。
|
配置客户端首页三个门面数字(帮助用户 / 完成比价 / 累计节省)的展示模式。每个指标可独立选 real/manual/random。用户侧读取见 [platform-stats](./platform-stats.md);表见 [ops_stat_config](../database/ops_stat_config.md)。
|
||||||
|
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# POST /admin/api/feedbacks/{feedback_id}/handle — 标记反馈已处理
|
# POST /admin/api/feedbacks/{feedback_id}/handle — 标记反馈已处理
|
||||||
|
|
||||||
> 所属: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")`) | [← 返回 API 索引](./README.md)
|
||||||
|
|
||||||
## 入参
|
## 入参
|
||||||
- 路径:`feedback_id`(int)
|
- 路径:`feedback_id`(int)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# GET /admin/api/feedbacks — 反馈工单列表(offset 分页 + 筛选/排序)
|
# GET /admin/api/feedbacks — 反馈工单列表(offset 分页 + 筛选/排序)
|
||||||
|
|
||||||
> 所属:Admin·反馈 组(前缀 `/admin/api/feedbacks`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 `require_role`,仅 `get_current_admin`) | [← 返回 API 索引](../../README.md)
|
> 所属:Admin·反馈 组(前缀 `/admin/api/feedbacks`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 `require_role`,仅 `get_current_admin`) | [← 返回 API 索引](./README.md)
|
||||||
|
|
||||||
## 入参(query)
|
## 入参(query)
|
||||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Admin 首页轮播种子管理
|
# Admin 首页轮播种子管理
|
||||||
|
|
||||||
> 所属:Admin 组(前缀 `/admin/api/marquee-seeds`) | 鉴权:Admin Bearer(改需 operator/super) | [← 返回 API 索引](../README.md)
|
> 所属: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](./platform-savings-feed.md);表见 [ops_marquee_seed](../database/ops_marquee_seed.md)。金额单位:分(前端 ÷100 显示元)。
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# GET /admin/api/stats/overview — 大盘核心指标
|
# GET /admin/api/stats/overview — 大盘核心指标
|
||||||
|
|
||||||
> 所属:Admin·数据大盘 组(前缀 `/admin/api/stats`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role) | [← 返回 API 索引](../README.md)
|
> 所属:Admin·数据大盘 组(前缀 `/admin/api/stats`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role) | [← 返回 API 索引](./README.md)
|
||||||
|
|
||||||
## 入参
|
## 入参
|
||||||
无
|
无
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# POST /admin/api/users/{user_id}/cash — 手动增减/设值现金(带审计)
|
# POST /admin/api/users/{user_id}/cash — 手动增减/设值现金(带审计)
|
||||||
|
|
||||||
> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](../../README.md)
|
> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md)
|
||||||
|
|
||||||
主要用于给无现金用户直接发钱、好让其测试提现链路。
|
主要用于给无现金用户直接发钱、好让其测试提现链路。
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# POST /admin/api/users/{user_id}/coins — 手动增减/设值金币(带审计)
|
# POST /admin/api/users/{user_id}/coins — 手动增减/设值金币(带审计)
|
||||||
|
|
||||||
> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](../../README.md)
|
> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md)
|
||||||
|
|
||||||
## 入参
|
## 入参
|
||||||
- 路径:`user_id`(int)
|
- 路径:`user_id`(int)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# GET /admin/api/users/{user_id} — 用户 360 详情
|
# GET /admin/api/users/{user_id} — 用户 360 详情
|
||||||
|
|
||||||
> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role) | [← 返回 API 索引](../../README.md)
|
> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role) | [← 返回 API 索引](./README.md)
|
||||||
|
|
||||||
## 入参
|
## 入参
|
||||||
- 路径:`user_id`(int)
|
- 路径:`user_id`(int)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# POST /admin/api/users/{user_id}/status — 封禁/解封用户
|
# POST /admin/api/users/{user_id}/status — 封禁/解封用户
|
||||||
|
|
||||||
> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:`operator`,`super_admin` 恒通过) | [← 返回 API 索引](../../README.md)
|
> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:`operator`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md)
|
||||||
|
|
||||||
## 入参
|
## 入参
|
||||||
- 路径:`user_id`(int)
|
- 路径:`user_id`(int)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# GET /admin/api/users — 用户列表(筛选+排序+分页)
|
# GET /admin/api/users — 用户列表(筛选+排序+分页)
|
||||||
|
|
||||||
> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role) | [← 返回 API 索引](../../README.md)
|
> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role) | [← 返回 API 索引](./README.md)
|
||||||
|
|
||||||
## 入参(query)
|
## 入参(query)
|
||||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# GET /admin/api/wallet/cash-transactions — 现金流水(游标分页)
|
# GET /admin/api/wallet/cash-transactions — 现金流水(游标分页)
|
||||||
|
|
||||||
> 所属:Admin·钱包 组(前缀 `/admin/api/wallet`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,仅需 `get_current_admin`,无 `require_role`) | [← 返回 API 索引](../../README.md)
|
> 所属:Admin·钱包 组(前缀 `/admin/api/wallet`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,仅需 `get_current_admin`,无 `require_role`) | [← 返回 API 索引](./README.md)
|
||||||
|
|
||||||
跨用户查询全量现金流水,可按 `user_id` / `biz_type` 过滤。游标分页(`id` 倒序)。金额单位一律为**分**。
|
跨用户查询全量现金流水,可按 `user_id` / `biz_type` 过滤。游标分页(`id` 倒序)。金额单位一律为**分**。
|
||||||
|
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# GET /admin/api/wallet/coin-transactions — 金币流水(游标分页)
|
# GET /admin/api/wallet/coin-transactions — 金币流水(游标分页)
|
||||||
|
|
||||||
> 所属:Admin·钱包 组(前缀 `/admin/api/wallet`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,仅需 `get_current_admin`,无 `require_role`) | [← 返回 API 索引](../../README.md)
|
> 所属:Admin·钱包 组(前缀 `/admin/api/wallet`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,仅需 `get_current_admin`,无 `require_role`) | [← 返回 API 索引](./README.md)
|
||||||
|
|
||||||
跨用户查询全量金币流水,可按 `user_id` / `biz_type` 过滤。游标分页(`id` 倒序)。
|
跨用户查询全量金币流水,可按 `user_id` / `biz_type` 过滤。游标分页(`id` 倒序)。
|
||||||
|
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# POST /admin/api/withdraws/reconcile — 批量对账(扫超时 pending 单)
|
# POST /admin/api/withdraws/reconcile — 批量对账(扫超时 pending 单)
|
||||||
|
|
||||||
> 所属:Admin·提现 组(前缀 `/admin/api/withdraws`) | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](../../README.md)
|
> 所属:Admin·提现 组(前缀 `/admin/api/withdraws`) | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md)
|
||||||
|
|
||||||
扫描创建时间超过 `older_than_minutes` 分钟、仍为 `pending` 的提现单,逐单调微信查单并归一化(成功落 `success`;失败/已撤销则退款落 `failed`;查到 `WAIT_USER_CONFIRM` 视为用户放弃,撤单+退款)。用于解开"扣了款但转账没发起/没确认"的孤儿单。单笔失败不影响其余(内部 rollback 后继续,下轮再试)。
|
扫描创建时间超过 `older_than_minutes` 分钟、仍为 `pending` 的提现单,逐单调微信查单并归一化(成功落 `success`;失败/已撤销则退款落 `failed`;查到 `WAIT_USER_CONFIRM` 视为用户放弃,撤单+退款)。用于解开"扣了款但转账没发起/没确认"的孤儿单。单笔失败不影响其余(内部 rollback 后继续,下轮再试)。
|
||||||
|
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# POST /admin/api/withdraws/{out_bill_no}/refresh — 单笔提现重试查单
|
# POST /admin/api/withdraws/{out_bill_no}/refresh — 单笔提现重试查单
|
||||||
|
|
||||||
> 所属:Admin·提现 组(前缀 `/admin/api/withdraws`) | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](../../README.md)
|
> 所属:Admin·提现 组(前缀 `/admin/api/withdraws`) | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md)
|
||||||
|
|
||||||
对单笔提现单调微信查单并归一化:`SUCCESS`→`success`;`FAIL`/`CANCELLED`/`CLOSED`→退款+`failed`;查到 `WAIT_USER_CONFIRM` 视为用户放弃(`cancel_if_unconfirmed=True`),撤单+退款;`ACCEPTED`/`PROCESSING` 等仍在途则保持 `pending`。已是终态的单直接返回、不再查。
|
对单笔提现单调微信查单并归一化:`SUCCESS`→`success`;`FAIL`/`CANCELLED`/`CLOSED`→退款+`failed`;查到 `WAIT_USER_CONFIRM` 视为用户放弃(`cancel_if_unconfirmed=True`),撤单+退款;`ACCEPTED`/`PROCESSING` 等仍在途则保持 `pending`。已是终态的单直接返回、不再查。
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# GET /admin/api/withdraws — 提现单列表(游标分页)
|
# GET /admin/api/withdraws — 提现单列表(游标分页)
|
||||||
|
|
||||||
> 所属:Admin·提现 组(前缀 `/admin/api/withdraws`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,列表为只读,仅需 `get_current_admin`,无 `require_role`) | [← 返回 API 索引](../../README.md)
|
> 所属:Admin·提现 组(前缀 `/admin/api/withdraws`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,列表为只读,仅需 `get_current_admin`,无 `require_role`) | [← 返回 API 索引](./README.md)
|
||||||
|
|
||||||
跨用户查询全量提现单,可按 `user_id` / `status` 过滤。游标分页(`id` 倒序)。
|
跨用户查询全量提现单,可按 `user_id` / `status` 过滤。游标分页(`id` 倒序)。
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# POST /api/v1/auth/jverify-login — 极光一键登录
|
# POST /api/v1/auth/jverify-login — 极光一键登录
|
||||||
|
|
||||||
> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:无 | [← 返回 API 索引](../README.md)
|
> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:无 | [← 返回 API 索引](./README.md)
|
||||||
>
|
>
|
||||||
> 集成实现:见 [integrations/jiguang](../integrations/jiguang.md)(极光核验链路、RSA 解密策略、私钥配对踩坑)。
|
> 集成实现:见 [integrations/jiguang](../integrations/jiguang.md)(极光核验链路、RSA 解密策略、私钥配对踩坑)。
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# POST /api/v1/auth/logout — 登出
|
# POST /api/v1/auth/logout — 登出
|
||||||
|
|
||||||
> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:Bearer access_token | [← 返回 API 索引](../README.md)
|
> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:Bearer access_token | [← 返回 API 索引](./README.md)
|
||||||
|
|
||||||
## 入参
|
## 入参
|
||||||
无
|
无
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# GET /api/v1/auth/me — 获取当前登录用户
|
# GET /api/v1/auth/me — 获取当前登录用户
|
||||||
|
|
||||||
> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:Bearer access_token | [← 返回 API 索引](../README.md)
|
> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:Bearer access_token | [← 返回 API 索引](./README.md)
|
||||||
|
|
||||||
## 入参
|
## 入参
|
||||||
无(身份取自 Header token)
|
无(身份取自 Header token)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# POST /api/v1/auth/refresh — 刷新 token
|
# POST /api/v1/auth/refresh — 刷新 token
|
||||||
|
|
||||||
> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:无(凭 body 里的 refresh_token) | [← 返回 API 索引](../README.md)
|
> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:无(凭 body 里的 refresh_token) | [← 返回 API 索引](./README.md)
|
||||||
|
|
||||||
## 入参
|
## 入参
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# POST /api/v1/auth/sms/login — 手机号 + 验证码登录
|
# POST /api/v1/auth/sms/login — 手机号 + 验证码登录
|
||||||
|
|
||||||
> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:无 | [← 返回 API 索引](../README.md)
|
> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:无 | [← 返回 API 索引](./README.md)
|
||||||
>
|
>
|
||||||
> 集成实现:见 [integrations/sms](../integrations/sms.md)(验证码校验逻辑)。
|
> 集成实现:见 [integrations/sms](../integrations/sms.md)(验证码校验逻辑)。
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# POST /api/v1/auth/sms/send — 发送短信验证码
|
# POST /api/v1/auth/sms/send — 发送短信验证码
|
||||||
|
|
||||||
> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:无 | [← 返回 API 索引](../README.md)
|
> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:无 | [← 返回 API 索引](./README.md)
|
||||||
>
|
>
|
||||||
> 集成实现:见 [integrations/sms](../integrations/sms.md)(mock 模式、频控、接真供应商 TODO)。
|
> 集成实现:见 [integrations/sms](../integrations/sms.md)(mock 模式、频控、接真供应商 TODO)。
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# POST /api/v1/intent/recognize — 外卖比价 Phase 1 意图识别(透传到 pricebot)
|
# POST /api/v1/intent/recognize — 外卖比价 Phase 1 意图识别(透传到 pricebot)
|
||||||
|
|
||||||
> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**无(MVP 阶段不鉴权)** | [← 返回 API 索引](../README.md)
|
> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**无(MVP 阶段不鉴权)** | [← 返回 API 索引](./README.md)
|
||||||
|
|
||||||
## 入参
|
## 入参
|
||||||
任意 JSON body,**不做 schema 校验**,原样透传给上游。后端仅从中读 `device_id`、`trace_id`、`step` 用于日志。
|
任意 JSON body,**不做 schema 校验**,原样透传给上游。后端仅从中读 `device_id`、`trace_id`、`step` 用于日志。
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# POST /api/v1/compare/milestones/{milestone}/claim — 领取比价战绩里程碑奖励
|
# POST /api/v1/compare/milestones/{milestone}/claim — 领取比价战绩里程碑奖励
|
||||||
|
|
||||||
> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](../README.md)
|
> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||||
|
|
||||||
领取某一档(第 `milestone` 次)。⚠️ **当前不真发金币**(产品定,后续整体删除该功能):仍写
|
领取某一档(第 `milestone` 次)。⚠️ **当前不真发金币**(产品定,后续整体删除该功能):仍写
|
||||||
`comparison_milestone_claim`((user_id, milestone) 唯一)标记该档已领、**每档只能领一次**,但不调
|
`comparison_milestone_claim`((user_id, milestone) 唯一)标记该档已领、**每档只能领一次**,但不调
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# GET /api/v1/compare/milestones — 比价战绩里程碑进度
|
# GET /api/v1/compare/milestones — 比价战绩里程碑进度
|
||||||
|
|
||||||
> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](../README.md)
|
> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||||
|
|
||||||
福利页「记录比价战绩」的数据源。返回各档(第 1~6 次)解锁/领取状态。
|
福利页「记录比价战绩」的数据源。返回各档(第 1~6 次)解锁/领取状态。
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# POST /api/v1/price/step — 外卖比价 Phase 2 步进(透传到 pricebot)
|
# POST /api/v1/price/step — 外卖比价 Phase 2 步进(透传到 pricebot)
|
||||||
|
|
||||||
> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**无(MVP 阶段不鉴权)** | [← 返回 API 索引](../README.md)
|
> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**无(MVP 阶段不鉴权)** | [← 返回 API 索引](./README.md)
|
||||||
|
|
||||||
## 入参
|
## 入参
|
||||||
任意 JSON body,**不做 schema 校验**,原样透传给上游。后端仅从中读 `device_id`、`trace_id`、`step` 用于日志。
|
任意 JSON body,**不做 schema 校验**,原样透传给上游。后端仅从中读 `device_id`、`trace_id`、`step` 用于日志。
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user