Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6016c12f9 | |||
| a8ac5dc0c7 | |||
| 2236a8b3ee | |||
| ee132aa93b | |||
| 4512b6ecac | |||
| 4630fd017b | |||
| 70c2349950 | |||
| 357b2312af | |||
| 78d970f420 | |||
| a3a14b484a | |||
| 208112ff24 | |||
| 32f300b5a2 | |||
| 81639ed388 | |||
| 5efe624340 | |||
| b622f76a02 | |||
| c0b67fd879 | |||
| 2ed62c789f | |||
| 1548406f29 | |||
| b7b958ed58 |
+70
-2
@@ -27,9 +27,57 @@ JG_PRIVATE_KEY_PATH=./secrets/jverify_rsa_private.pem
|
||||
JG_VERIFY_ENDPOINT=https://api.verification.jpush.cn/v1/web/loginTokenVerify
|
||||
JG_REQUEST_TIMEOUT_SEC=15
|
||||
|
||||
# ===== 无障碍保护存活监控(pull 后置检测;本期不接推送)=====
|
||||
# ===== 厂商直推(无障碍保护存活告警 + 消息中心 13 类通知)=====
|
||||
# 敏感密钥只放 .env / 服务器环境变量,不要提交到 git。
|
||||
# 各厂商配置状态可随时 GET /api/v1/push/vendors 查看(缺哪些键一目了然)。
|
||||
ANDROID_PACKAGE_NAME=com.jishisongfu.shaguabijia
|
||||
PUSH_REQUEST_TIMEOUT_SEC=15
|
||||
PUSH_TIME_TO_LIVE_SEC=86400
|
||||
|
||||
HONOR_PUSH_APP_ID=
|
||||
HONOR_PUSH_CLIENT_ID=
|
||||
HONOR_PUSH_CLIENT_SECRET=
|
||||
HONOR_PUSH_TOKEN_ENDPOINT=https://iam.developer.honor.com/auth/token
|
||||
HONOR_PUSH_SEND_ENDPOINT_TEMPLATE=https://push-api.cloud.honor.com/api/v1/{app_id}/sendMessage
|
||||
|
||||
# 华为 Push Kit:AGC 控制台 → 项目设置 → 常规 → 应用,AppId + AppSecret
|
||||
HUAWEI_PUSH_APP_ID=
|
||||
HUAWEI_PUSH_APP_SECRET=
|
||||
HUAWEI_PUSH_TOKEN_ENDPOINT=https://oauth-login.cloud.huawei.com/oauth2/v3/token
|
||||
HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE=https://push-api.cloud.huawei.com/v1/{app_id}/messages:send
|
||||
|
||||
VIVO_PUSH_APP_ID=
|
||||
VIVO_PUSH_APP_KEY=
|
||||
VIVO_PUSH_APP_SECRET=
|
||||
VIVO_PUSH_AUTH_ENDPOINT=https://api-push.vivo.com.cn/message/auth
|
||||
VIVO_PUSH_SEND_ENDPOINT=https://api-push.vivo.com.cn/message/send
|
||||
# vivo 未上架测试时可用 push_mode=1; 上架正式推送改为 0。
|
||||
VIVO_PUSH_MODE=1
|
||||
VIVO_PUSH_NOTIFY_TYPE=4
|
||||
VIVO_PUSH_CATEGORY=DEVICE_REMINDER
|
||||
|
||||
XIAOMI_PUSH_APP_SECRET=
|
||||
XIAOMI_PUSH_SEND_ENDPOINT=https://api.xmpush.xiaomi.com/v3/message/regid
|
||||
XIAOMI_PUSH_CHANNEL_ID=
|
||||
XIAOMI_PUSH_TEMPLATE_ID=
|
||||
XIAOMI_PUSH_TEMPLATE_TITLE=
|
||||
XIAOMI_PUSH_TEMPLATE_DESCRIPTION=
|
||||
# 可选: JSON 字符串,支持 {title}/{alert} 占位符,例如 {"title":"{title}","content":"{alert}"}
|
||||
XIAOMI_PUSH_TEMPLATE_PARAM_JSON=
|
||||
|
||||
OPPO_PUSH_APP_KEY=
|
||||
OPPO_PUSH_MASTER_SECRET=
|
||||
OPPO_PUSH_AUTH_ENDPOINT=https://api.push.oppomobile.com/server/v1/auth
|
||||
OPPO_PUSH_SEND_ENDPOINT=https://api.push.oppomobile.com/server/v1/message/notification/unicast
|
||||
# OPPO 新消息分类(2024-11-20 后创建的应用必须携带 category;channel_id 为后台「通道ID」;
|
||||
# notify_level 0=不传走默认,内容营销类仅支持 1/2)
|
||||
OPPO_PUSH_CHANNEL_ID=
|
||||
OPPO_PUSH_CATEGORY=
|
||||
OPPO_PUSH_NOTIFY_LEVEL=0
|
||||
|
||||
# ===== 无障碍保护存活监控(推送 + pull 后置兜底)=====
|
||||
HEARTBEAT_MONITOR_ENABLED=true
|
||||
HEARTBEAT_TIMEOUT_MINUTES=10
|
||||
HEARTBEAT_TIMEOUT_MINUTES=60
|
||||
HEARTBEAT_SCAN_INTERVAL_SEC=60
|
||||
|
||||
# ===== 短信 (mock 模式) =====
|
||||
@@ -58,6 +106,13 @@ MT_CPS_DEFAULT_SID=sgbjia
|
||||
# 线上国内服务器留空(=直连)。留空且本机直连失败时 /feed、/coupons、/top-sales 会返回空。
|
||||
MT_CPS_PROXY=
|
||||
|
||||
# ===== 京东联盟 CPS =====
|
||||
# 京东联盟/京东宙斯开放平台创建应用后填写。AUTH_KEY 是工具商授权 key,自有应用可留空。
|
||||
JD_UNION_APP_KEY=
|
||||
JD_UNION_APP_SECRET=
|
||||
JD_UNION_SITE_ID=
|
||||
JD_UNION_AUTH_KEY=
|
||||
|
||||
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
|
||||
# 客户端调本服务的 /api/v1/coupon/step 等,我们透传到 pricebot-backend。
|
||||
# 本地开发用 localhost:8000。生产部署改成内网地址(如 http://pricebot.internal:8000)。
|
||||
@@ -117,3 +172,16 @@ PANGLE_REWARD_SECRET=
|
||||
# ⚠️ 仅本地联调:true 时开放 POST /api/v1/ad/test-grant,让 debug 客户端看完广告直接发奖,
|
||||
# 验证"看广告→金币到账"全链路(未部署公网、穿山甲 S2S 打不到本地时用)。生产必须 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
|
||||
|
||||
+5
-2
@@ -23,14 +23,17 @@ dist/
|
||||
*.db-journal
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
# data/ 整体不入库(运行时数据/上传文件/大二进制)。例外:邀请落地页 dl.html——
|
||||
# 它既是生产落地页又是本地测试资产,纳入 git 便于同事一致测试
|
||||
# data/ 整体不入库(运行时数据/上传文件/大二进制)。例外:邀请落地页 dl.html 及其
|
||||
# 引用的静态插画(coupon-page-bg.png 底图 + sb-brand.png logo)——既是生产落地页资产
|
||||
# 又是本地测试资产,纳入 git 便于同事一致测试、且随部署进生产 /media(否则线上 404→落地页毛坯)。
|
||||
# (见 docs/邀请功能-实现原理与本地测试.md)。其余(avatars/ / *.apk / app.db 等)仍忽略。
|
||||
data/*
|
||||
!data/media/
|
||||
data/media/*
|
||||
!data/media/dl.html
|
||||
!data/media/taobao_landing.jpg
|
||||
!data/media/coupon-page-bg.png
|
||||
!data/media/sb-brand.png
|
||||
|
||||
secrets/*
|
||||
!secrets/.gitkeep
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""merge direct_vendor_push and feedback_type_reply heads
|
||||
|
||||
Revision ID: 1a924c274fce
|
||||
Revises: direct_vendor_push_fields, feedback_type_reply
|
||||
Create Date: 2026-07-14 18:53:02.856979
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '1a924c274fce'
|
||||
down_revision: Union[str, Sequence[str], None] = ('direct_vendor_push_fields', 'feedback_type_reply')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,26 @@
|
||||
"""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
|
||||
@@ -0,0 +1,63 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,81 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,30 @@
|
||||
"""add direct vendor push fields
|
||||
|
||||
Revision ID: direct_vendor_push_fields
|
||||
Revises: jd_cps_order_fields
|
||||
Create Date: 2026-07-01 16:30:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "direct_vendor_push_fields"
|
||||
down_revision = "jd_cps_order_fields"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("device_liveness") as batch_op:
|
||||
batch_op.add_column(sa.Column("push_vendor", sa.String(length=32), nullable=True))
|
||||
batch_op.add_column(sa.Column("push_token", sa.String(length=256), nullable=True))
|
||||
batch_op.create_index("ix_device_liveness_push_vendor", ["push_vendor"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("device_liveness") as batch_op:
|
||||
batch_op.drop_index("ix_device_liveness_push_vendor")
|
||||
batch_op.drop_column("push_token")
|
||||
batch_op.drop_column("push_vendor")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,56 @@
|
||||
"""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,6 +21,7 @@ from app.admin.routers.audit import router as audit_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.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.dashboard import router as dashboard_router
|
||||
from app.admin.routers.device_liveness import router as device_liveness_router
|
||||
@@ -101,6 +102,7 @@ admin_app.include_router(audit_router)
|
||||
admin_app.include_router(config_router)
|
||||
admin_app.include_router(comparison_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_config_router)
|
||||
admin_app.include_router(ad_revenue_router)
|
||||
|
||||
@@ -136,12 +136,16 @@ def _feed_scene_matches(rec: AdFeedRewardRecord, scene: str | None) -> bool:
|
||||
"""该信息流记录是否落入请求的展示筛选 scene。
|
||||
- scene=="feed":ad_type in ("feed", NULL)(旧数据 NULL 视为 feed,向后兼容)
|
||||
- scene=="draw":ad_type=="draw"
|
||||
- scene=="feed_all":所有信息流(feed/draw/NULL 都要)——业务已全切 Draw 信息流,收益报表把「Draw 信息流」
|
||||
当作整个信息流口径(含历史误标 feed/NULL),用它避免筛选漏历史。
|
||||
- scene 为 None:不筛(两类都要)。
|
||||
"""
|
||||
if scene == "feed":
|
||||
return rec.ad_type in (None, "feed")
|
||||
if scene == "draw":
|
||||
return rec.ad_type == "draw"
|
||||
if scene == "feed_all":
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
@@ -184,6 +188,7 @@ def _feed_rows(
|
||||
"record_id": rec.id,
|
||||
"user_id": rec.user_id,
|
||||
"ad_session_id": rec.ad_session_id,
|
||||
"trace_id": rec.trace_id,
|
||||
"app_env": rec.app_env,
|
||||
"our_code_id": rec.our_code_id,
|
||||
"created_at": rec.created_at,
|
||||
@@ -209,6 +214,7 @@ def _feed_rows(
|
||||
"record_id": rec.id,
|
||||
"user_id": rec.user_id,
|
||||
"ad_session_id": rec.ad_session_id,
|
||||
"trace_id": rec.trace_id,
|
||||
"app_env": rec.app_env,
|
||||
"our_code_id": rec.our_code_id,
|
||||
"created_at": rec.created_at,
|
||||
@@ -241,7 +247,7 @@ def audit_rows(
|
||||
rows: list[dict] = []
|
||||
if scene in (None, "reward_video"):
|
||||
rows.extend(_reward_video_rows(db, date=date, user_id=user_id))
|
||||
if scene in (None, "feed", "draw"):
|
||||
if scene in (None, "feed", "draw", "feed_all"):
|
||||
rows.extend(_feed_rows(db, date=date, user_id=user_id, scene=scene))
|
||||
return rows
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
只读。每行 = 一次广告事件(不再按用户聚合):
|
||||
- **激励视频**:一次观看 = 1 条展示(ad_ecpm)+ 1 条发奖(ad_reward),按 ad_session_id 合并成一行,
|
||||
直接给出 eCPM / 收益 + 状态 / 应发 / 实发 / 一致;点开看该条金币复算因子。
|
||||
- **信息流**:轮播每条展示各一行(impressionId 各自独立);整场发奖(ad_feed_reward,client_event_id)
|
||||
与逐条展示无法对应,单独成「纯发奖」行。
|
||||
- 兜底:有展示无发奖(中途关 / 未达发奖)、有发奖无展示(未上报 eCPM)都各自成行。
|
||||
- **信息流(比价/领券)**:一次比价 / 一次领券 = 一条整场发奖(ad_feed_reward)一行,给出 eCPM /
|
||||
发奖金币 + 应发 / 实发 / 一致;点开看金币复算因子。⚠️ draw 的逐条展示(ad_ecpm,impressionId 各自
|
||||
独立、与整场发奖无公共键、无法归到「哪一次」)**不再单独占行**(2026-07 按「一次比价/领券放一块」调整)——
|
||||
其展示数 / eCPM / 预估收益仍进全量统计(合计 / 趋势 / 分类大盘 / 穿山甲对照),只是主表不逐条铺开。
|
||||
- 兜底:激励视频有展示无发奖(中途关 / 未达发奖)、有发奖无展示(未上报 eCPM)仍各自成行。
|
||||
|
||||
展示与收益来自 ad_ecpm_record(收益 = eCPM元 ÷ 1000);应发 / 实发金币复用金币审计逐条复算
|
||||
(ad_audit.audit_rows,与正式发奖同一公式口径,不另写公式)。合计与对账在全量上统计,
|
||||
@@ -31,6 +33,7 @@ from app.admin.repositories import stats as admin_stats
|
||||
from app.core import rewards
|
||||
from app.models.ad_ecpm import AdEcpmRecord
|
||||
from app.models.user import User
|
||||
from app.repositories import ad_pangle_revenue
|
||||
|
||||
|
||||
def _cn_hour(dt: datetime) -> int:
|
||||
@@ -57,14 +60,6 @@ def _date_range(date_from: str, date_to: str) -> list[str]:
|
||||
_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。
|
||||
_REWARD_DETAIL_KEYS = (
|
||||
"record_id", "created_at", "status", "ecpm", "ecpm_factor", "units",
|
||||
@@ -86,6 +81,7 @@ def ad_revenue_report(
|
||||
user_id: int | None = None,
|
||||
ad_type: str | None = None,
|
||||
feed_scene: str | None = None,
|
||||
app_env: str | None = None,
|
||||
granularity: str = "day",
|
||||
limit: int = 500,
|
||||
offset: int = 0,
|
||||
@@ -106,9 +102,14 @@ def ad_revenue_report(
|
||||
# 同时保留全量列表,未被展示合并的成「纯发奖」事件。
|
||||
reward_by_session: dict[tuple[int, str], list[dict]] = {}
|
||||
all_reward_rows: list[dict] = []
|
||||
# 报表 ad_type 直接当 audit scene 用(取值一致);未知/无效 ad_type 不取发奖行。draw 在此被
|
||||
# 正确传成 scene="draw",audit 会按 ad_type 筛出 Draw 发奖,不再丢成 feed。
|
||||
audit_scene = ad_type if ad_type in _AUDIT_SCENES else None
|
||||
# 报表 ad_type → audit scene:reward_video/feed 直传;**draw(前端「Draw 信息流」)映射成 feed_all**
|
||||
# ——业务已全切 Draw,把「Draw 信息流」当作整个信息流口径(含历史误标 feed/NULL),否则筛选会漏历史。
|
||||
if ad_type == "draw":
|
||||
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:
|
||||
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):
|
||||
@@ -138,7 +139,10 @@ def ad_revenue_report(
|
||||
)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(AdEcpmRecord.user_id == user_id)
|
||||
if ad_type is not None:
|
||||
if ad_type == "draw":
|
||||
# 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)
|
||||
for rec in db.execute(stmt).scalars():
|
||||
rwd = _pop_reward(rec.user_id, rec.ad_session_id)
|
||||
@@ -155,10 +159,16 @@ def ad_revenue_report(
|
||||
"has_impression": True,
|
||||
"impressions": 1,
|
||||
"ecpm": rec.ecpm_raw,
|
||||
# 单次展示收益(元)= eCPM元 ÷ 1000(每千次→单次);与发奖同源解析,口径一致。
|
||||
"revenue_yuan": round(rewards.parse_ecpm_yuan(rec.ecpm_raw) / 1000.0, 6),
|
||||
# 单次展示收益(元)= eCPM元 ÷ 1000(每千次→单次)。eCPM 先钳到 AD_ECPM_MAX_FEN(¥500 CPM)
|
||||
# 再折收益,与发奖口径 [rewards.calculate_ad_reward_coin] 一致(2026-06-29 修:原裸 parse_ecpm_yuan
|
||||
# 不钳,伪造/异常天价 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,
|
||||
"slot_id": rec.slot_id,
|
||||
"sub_rewards": [],
|
||||
"sub_count": 1,
|
||||
}
|
||||
if rwd is not None:
|
||||
ev.update({
|
||||
@@ -178,33 +188,88 @@ def ad_revenue_report(
|
||||
})
|
||||
events.append(ev)
|
||||
|
||||
# 3) 未被展示合并的发奖行 → 「纯发奖」事件(信息流整场发奖 / 有发奖无展示)。
|
||||
# 收益恒 0(收益只算展示侧,避免与展示行重复计)。
|
||||
# 3) 未被展示合并的发奖行 → 事件:
|
||||
# - 激励视频(reward_video):逐条成「纯发奖」事件(每次一个 ad_session_id;有发奖无展示等)。
|
||||
# - 信息流(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:
|
||||
if row["record_id"] in used_reward_ids:
|
||||
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({
|
||||
"event_key": f"rwd-{row['record_id']}",
|
||||
"report_date": row["_report_date"],
|
||||
"user_id": row["user_id"],
|
||||
"ad_type": _event_ad_type(row),
|
||||
"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,
|
||||
"event_key": f"feedgrp-{uid}-{grp_key}",
|
||||
"report_date": rep["_report_date"],
|
||||
"user_id": uid,
|
||||
"ad_type": "draw", # 业务已全切 Draw 信息流,聚合行统一 draw
|
||||
"feed_scene": rep.get("feed_scene"),
|
||||
"app_env": rep.get("app_env"),
|
||||
"our_code_id": rep.get("our_code_id"),
|
||||
"created_at": rep["created_at"],
|
||||
"hour": _cn_hour(rep["created_at"]) if by_hour else None,
|
||||
"has_impression": False,
|
||||
"impressions": 0,
|
||||
"ecpm": row["ecpm"],
|
||||
"ecpm": avg_ecpm,
|
||||
"revenue_yuan": 0.0,
|
||||
"row_revenue_yuan": row_revenue,
|
||||
"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),
|
||||
"status": rep["status"], # 代表状态(逐条见展开)
|
||||
"expected_coin": expected_sum,
|
||||
"actual_coin": actual_sum,
|
||||
"matched": all(bool(g["matched"]) for g in group),
|
||||
"reward_detail": None,
|
||||
"sub_rewards": [_reward_detail(g) for g in group],
|
||||
"sub_count": len(group),
|
||||
})
|
||||
|
||||
# 「场景」作为全局筛选(与 user_id/ad_type 一致):同时作用于明细、合计与 daily/hourly 趋势。
|
||||
@@ -212,6 +277,14 @@ def ad_revenue_report(
|
||||
if feed_scene is not None:
|
||||
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 原值是字符串「分」,转数值排;
|
||||
# 纯发奖行用其发奖采用的 eCPM,缺失/非法计 0 排末尾)。
|
||||
if sort == "ecpm":
|
||||
@@ -255,6 +328,25 @@ def ad_revenue_report(
|
||||
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 时用)。
|
||||
# 只在 by_hour 下聚合(此时每个 event 带 hour);否则空。前端按天趋势仍用 daily。
|
||||
hourly: list[dict] = []
|
||||
@@ -298,11 +390,26 @@ def ad_revenue_report(
|
||||
is_today = date_from == date_to == rewards.cn_today().isoformat()
|
||||
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 {
|
||||
"total": len(events),
|
||||
"truncated": len(events) > offset + limit,
|
||||
"total": len(main_rows),
|
||||
"truncated": len(main_rows) > offset + limit,
|
||||
"total_impressions": total_impressions,
|
||||
"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_actual_coin": total_actual_coin,
|
||||
"mismatch_count": mismatch_count,
|
||||
@@ -310,5 +417,5 @@ def ad_revenue_report(
|
||||
"hourly": hourly,
|
||||
"type_stats": type_stats,
|
||||
"dau": dau,
|
||||
"items": events[offset:offset + limit],
|
||||
"items": main_rows[offset:offset + limit],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""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,13 +7,14 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.admin.repositories.queries import _as_utc, offset_paginate
|
||||
from app.integrations import meituan
|
||||
from app.integrations import jd_union, meituan
|
||||
from app.repositories import cps_link as cps_link_repo
|
||||
from app.models.cps_activity import CpsActivity
|
||||
from app.models.cps_group import CpsGroup
|
||||
@@ -24,6 +25,11 @@ from app.models.cps_wx_user import CpsWxUser
|
||||
# 美团订单状态:取消(4)/风控(5)不计佣金;结算(6)为佣金真正到账
|
||||
_INVALID_STATUS = {"4", "5"}
|
||||
_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 点击时序按北京时区分桶(运营看的是北京时间)
|
||||
_BJ_TZ = timezone(timedelta(hours=8))
|
||||
@@ -47,6 +53,36 @@ def _ts_to_dt(ts: object) -> datetime | None:
|
||||
"""秒级时间戳 → tz-aware UTC datetime(绝对时刻,前端按北京展示)。"""
|
||||
if not ts:
|
||||
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:
|
||||
return datetime.fromtimestamp(int(ts), tz=timezone.utc)
|
||||
except (ValueError, OSError, TypeError):
|
||||
@@ -249,6 +285,80 @@ 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(
|
||||
db: Session, *, start_time: int, end_time: int,
|
||||
query_time_type: int = 1, sid: str | None = None, max_pages: int = 200,
|
||||
@@ -274,6 +384,11 @@ def reconcile_orders(
|
||||
continue
|
||||
fetched += 1
|
||||
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(
|
||||
select(CpsOrder).where(CpsOrder.order_id == order_id)
|
||||
).scalar_one_or_none()
|
||||
@@ -291,6 +406,56 @@ def reconcile_orders(
|
||||
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(
|
||||
db: Session, *, sid: str | None = None, mt_status: str | None = None,
|
||||
limit: int = 20, cursor: int | None = None,
|
||||
|
||||
@@ -65,16 +65,19 @@ def review_feedback(
|
||||
reward_coins: int | None = None,
|
||||
reject_reason: str | None = None,
|
||||
review_note: str | None = None,
|
||||
admin_reply: str | None = None,
|
||||
commit: bool = True,
|
||||
) -> Feedback:
|
||||
"""审核反馈:置 adopted/rejected + 记录奖励/原因/审核人/审核时间。
|
||||
"""审核反馈:置 adopted/rejected + 记录奖励/原因/回复留言/审核人/审核时间。
|
||||
|
||||
发金币(wallet.grant_coins)由 router 在同一事务里调,确保状态、金币流水、审计一起提交。
|
||||
admin_reply=给用户的回复留言(用户端可见),与 review_note(内部备注)区分。
|
||||
"""
|
||||
feedback.status = status
|
||||
feedback.reward_coins = reward_coins
|
||||
feedback.reject_reason = reject_reason
|
||||
feedback.review_note = review_note
|
||||
feedback.admin_reply = admin_reply
|
||||
feedback.reviewed_by_admin_id = reviewed_by_admin_id
|
||||
feedback.reviewed_at = datetime.now(CN_TZ).replace(tzinfo=None)
|
||||
if commit:
|
||||
|
||||
@@ -125,8 +125,9 @@ def list_users(
|
||||
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
|
||||
|
||||
|
||||
def _attach_user_info(db: Session, records: list[ComparisonRecord]) -> None:
|
||||
"""给每条比价记录瞬态挂 phone/nickname(非 DB 列,供 admin schema from_attributes 读)。"""
|
||||
def _attach_user_info(db: Session, records: list[ComparisonRecord | Feedback | PriceReport]) -> None:
|
||||
"""给每条记录瞬态挂 phone/nickname(非 DB 列,供 admin schema from_attributes 读)。
|
||||
按 user_id 鸭子类型,比价记录/反馈/上报通用。"""
|
||||
uids = {r.user_id for r in records}
|
||||
if not uids:
|
||||
return
|
||||
@@ -389,6 +390,7 @@ def list_all_withdraw_orders(
|
||||
*,
|
||||
user_id: int | None = None,
|
||||
status: str | None = None,
|
||||
source: str | None = None,
|
||||
keyword: str | None = None,
|
||||
date_from: datetime | None = None,
|
||||
date_to: datetime | None = None,
|
||||
@@ -408,6 +410,9 @@ def list_all_withdraw_orders(
|
||||
stmt = stmt.where(WithdrawOrder.user_id == user_id)
|
||||
if status:
|
||||
stmt = stmt.where(WithdrawOrder.status == status)
|
||||
# 提现类型:coin_cash(福利页提现)/ invite_cash(邀请提现);None=全部
|
||||
if source:
|
||||
stmt = stmt.where(WithdrawOrder.source == source)
|
||||
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
@@ -533,6 +538,7 @@ def list_feedbacks(
|
||||
db: Session,
|
||||
*,
|
||||
status: str | None = None,
|
||||
source: str | None = None,
|
||||
user_id: int | None = None,
|
||||
content: str | None = None,
|
||||
created_from: datetime | None = None,
|
||||
@@ -542,13 +548,15 @@ def list_feedbacks(
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[Feedback], int | None, int]:
|
||||
"""反馈工单列表。支持 状态 / 用户ID / 内容模糊 / 提交时间范围 筛选,按 id·提交时间排序。
|
||||
**offset 分页**(cursor=offset):任意列排序下游标语义统一(同 [list_users]),代价是翻页期间
|
||||
数据变动可能错位一条——admin 低频场景可接受。返回 (items, next_cursor, total),total 供页码分页。
|
||||
created_at 为 timestamptz,日期入参统一转 tz-aware UTC 比较。"""
|
||||
"""反馈工单列表。支持 状态 / 反馈类型(source) / 用户ID / 内容模糊 / 提交时间范围 筛选,
|
||||
按 id·提交时间排序。**offset 分页**(cursor=offset):任意列排序下游标语义统一(同 [list_users]),
|
||||
代价是翻页期间数据变动可能错位一条——admin 低频场景可接受。返回 (items, next_cursor, total),
|
||||
total 供页码分页。created_at 为 timestamptz,日期入参统一转 tz-aware UTC 比较。"""
|
||||
stmt = select(Feedback)
|
||||
if status:
|
||||
stmt = stmt.where(Feedback.status == status)
|
||||
if source:
|
||||
stmt = stmt.where(Feedback.source == source)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(Feedback.user_id == user_id)
|
||||
if content and content.strip():
|
||||
@@ -562,7 +570,25 @@ def list_feedbacks(
|
||||
sort_col = sort_cols.get(sort_by, Feedback.id)
|
||||
order_fn = asc if sort_order == "asc" else desc
|
||||
id_order = asc(Feedback.id) if sort_order == "asc" else desc(Feedback.id)
|
||||
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
|
||||
items, next_cursor, total = offset_paginate(
|
||||
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(
|
||||
@@ -769,6 +795,7 @@ def get_user_overview(db: Session, user_id: int) -> dict | None:
|
||||
"user": user,
|
||||
"coin_balance": acc.coin_balance 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,
|
||||
"comparison_total": _count(ComparisonRecord, ComparisonRecord.user_id == user_id),
|
||||
"comparison_success": _count(
|
||||
@@ -786,6 +813,12 @@ 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:
|
||||
"""把 [date_from, date_to] 转成对 col(created_at)的过滤条件;都为 None = 全量(注册至今)。"""
|
||||
conds = []
|
||||
@@ -875,6 +908,13 @@ 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(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
@@ -895,6 +935,9 @@ def user_coin_records(
|
||||
offset = max(cursor or 0, 0)
|
||||
fetch = offset + limit + 1
|
||||
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(
|
||||
select(AdRewardRecord)
|
||||
@@ -938,7 +981,7 @@ def user_coin_records(
|
||||
.where(
|
||||
CoinTransaction.user_id == user_id,
|
||||
CoinTransaction.biz_type == "signin",
|
||||
*_window_conds(CoinTransaction.created_at, date_from, date_to),
|
||||
*_window_conds(CoinTransaction.created_at, signin_from, signin_to),
|
||||
)
|
||||
.order_by(CoinTransaction.created_at.desc())
|
||||
.limit(fetch)
|
||||
@@ -946,7 +989,8 @@ def user_coin_records(
|
||||
rows.append({
|
||||
"source": "signin",
|
||||
"source_label": "签到",
|
||||
"created_at": rec.created_at,
|
||||
# 北京 wall-clock → UTC,与广告记录统一(前端 apiTime 会 +8 回北京展示,不然签到会多 8 小时)
|
||||
"created_at": _cn_wall_to_utc(rec.created_at),
|
||||
"ecpm": None,
|
||||
"coin": rec.amount,
|
||||
})
|
||||
@@ -972,27 +1016,72 @@ def user_coin_records(
|
||||
+ _count(
|
||||
CoinTransaction, CoinTransaction.user_id == user_id,
|
||||
CoinTransaction.biz_type == "signin",
|
||||
*_window_conds(CoinTransaction.created_at, date_from, date_to),
|
||||
*_window_conds(CoinTransaction.created_at, signin_from, signin_to),
|
||||
)
|
||||
)
|
||||
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(
|
||||
db: Session,
|
||||
*,
|
||||
status: str | None = None,
|
||||
user_id: int | None = None,
|
||||
sort_by: str = "id",
|
||||
sort_order: str = "desc",
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> 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)
|
||||
if status:
|
||||
stmt = stmt.where(PriceReport.status == status)
|
||||
if user_id is not None:
|
||||
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:
|
||||
|
||||
+418
-14
@@ -5,20 +5,44 @@ user.last_login_at / comparison_record.status / withdraw_order.status)要加索
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||
from app.models.ad_reward import AdRewardRecord
|
||||
from app.models.analytics_event import AnalyticsEvent
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.coupon_state import CouponPromptEngagement
|
||||
from app.models.cps_order import CpsOrder
|
||||
from app.models.feedback import Feedback
|
||||
from app.models.savings import SavingsRecord
|
||||
from app.models.signin import SigninBoostRecord, SigninRecord
|
||||
from app.models.user import User
|
||||
from app.models.wallet import CoinTransaction, WithdrawOrder
|
||||
|
||||
_BEIJING = timezone(timedelta(hours=8))
|
||||
COUPON_REWARD_BIZ_TYPES = ("reward_video", "ad_reward", "coupon", "coupon_reward")
|
||||
COMPARISON_REWARD_BIZ_TYPES = ("comparison", "compare_reward", "comparison_reward")
|
||||
EXCLUDED_REWARD_BIZ_TYPES = ("invite_inviter", "invite_invitee", "admin_grant")
|
||||
UNCLASSIFIED_FEED_BIZ_TYPES = ("feed_ad_reward",)
|
||||
REGULAR_TASK_EXCLUDED_BIZ_TYPES = (
|
||||
*COUPON_REWARD_BIZ_TYPES,
|
||||
*COMPARISON_REWARD_BIZ_TYPES,
|
||||
*EXCLUDED_REWARD_BIZ_TYPES,
|
||||
*UNCLASSIFIED_FEED_BIZ_TYPES,
|
||||
)
|
||||
MEITUAN_CPS_INVALID_STATUSES = ("4", "5")
|
||||
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:
|
||||
@@ -29,32 +53,129 @@ def _beijing_today_start_utc() -> datetime:
|
||||
|
||||
|
||||
def today_dau(db: Session) -> int:
|
||||
"""今日活跃用户数(DAU):北京时区今天 0 点后登录过(last_login_at)。
|
||||
"""今日活跃用户数(DAU):登录 + 开始比价 + 开始领券,按用户去重。
|
||||
|
||||
大盘与广告收益报表共用此口径,单一来源避免漂移。
|
||||
⚠️ last_login_at 是单值字段(只存最后一次登录时刻),故只能算「今日」,
|
||||
无法回溯历史某天的 DAU——调用方按此约束决定历史区间是否展示。
|
||||
广告收益报表复用这个函数;历史窗口 DAU 由 dashboard_overview 的 period 口径另算。
|
||||
"""
|
||||
today_bj = datetime.now(_BEIJING).date()
|
||||
today_start = _beijing_today_start_utc()
|
||||
return db.execute(
|
||||
select(func.count(User.id)).where(User.last_login_at >= today_start)
|
||||
).scalar_one()
|
||||
tomorrow_start = today_start + timedelta(days=1)
|
||||
login_user_ids = _id_set(
|
||||
db,
|
||||
select(User.id).where(User.last_login_at >= today_start, User.last_login_at < tomorrow_start),
|
||||
)
|
||||
compare_start_user_ids = _event_user_ids(
|
||||
db, (COMPARE_START_EVENT,), today_start, tomorrow_start
|
||||
)
|
||||
coupon_event_user_ids = _event_user_ids(
|
||||
db, (COUPON_START_EVENT,), today_start, tomorrow_start
|
||||
)
|
||||
coupon_claim_user_ids = _id_set(
|
||||
db,
|
||||
select(CouponPromptEngagement.user_id).where(
|
||||
CouponPromptEngagement.engage_date == today_bj,
|
||||
CouponPromptEngagement.engage_type == "claim_started",
|
||||
),
|
||||
)
|
||||
return len(login_user_ids | compare_start_user_ids | coupon_event_user_ids | coupon_claim_user_ids)
|
||||
|
||||
|
||||
def dashboard_overview(db: Session) -> dict:
|
||||
def _default_period_end() -> date:
|
||||
"""新版大盘不含今日,默认窗口结束日=北京时间昨天。"""
|
||||
return datetime.now(_BEIJING).date() - timedelta(days=1)
|
||||
|
||||
|
||||
def _normalize_period(date_from: date | None, date_to: date | None) -> tuple[date, date]:
|
||||
end = date_to or _default_period_end()
|
||||
start = date_from or end
|
||||
if start > end:
|
||||
start, end = end, start
|
||||
return start, end
|
||||
|
||||
|
||||
def _period_bounds(date_from: date, date_to: date) -> tuple[datetime, datetime, datetime, datetime]:
|
||||
"""返回同一北京自然日窗口的 UTC aware 边界和北京 naive 边界。
|
||||
|
||||
user.created_at / last_login_at 是 UTC aware 口径;比较/金币等历史上有北京 naive
|
||||
写入,所以两套边界同时保留。
|
||||
"""
|
||||
start_bj = datetime.combine(date_from, time.min, tzinfo=_BEIJING)
|
||||
end_bj = datetime.combine(date_to + timedelta(days=1), time.min, tzinfo=_BEIJING)
|
||||
start_utc = start_bj.astimezone(timezone.utc)
|
||||
end_utc = end_bj.astimezone(timezone.utc)
|
||||
return (
|
||||
start_utc,
|
||||
end_utc,
|
||||
start_bj.replace(tzinfo=None),
|
||||
end_bj.replace(tzinfo=None),
|
||||
)
|
||||
|
||||
|
||||
def _date_range(date_from: date, date_to: date) -> list[date]:
|
||||
days = (date_to - date_from).days
|
||||
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:
|
||||
"""美团 commissionRate 原值: "300"=3%, "10"=0.1%;也兼容 "3%"。"""
|
||||
if raw is None:
|
||||
return None
|
||||
s = str(raw).strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
if s.endswith("%"):
|
||||
return Decimal(s[:-1])
|
||||
val = Decimal(s)
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
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(
|
||||
db: Session, *, date_from: date | None = None, date_to: date | None = None
|
||||
) -> dict:
|
||||
today_start = _beijing_today_start_utc()
|
||||
period_from, period_to = _normalize_period(date_from, date_to)
|
||||
start_utc, end_utc, start_local, end_local = _period_bounds(period_from, period_to)
|
||||
|
||||
def _count(model, *conds) -> int:
|
||||
stmt = select(func.count(model.id))
|
||||
if conds:
|
||||
stmt = stmt.where(*conds)
|
||||
return db.execute(stmt).scalar_one()
|
||||
return int(db.execute(stmt).scalar_one())
|
||||
|
||||
def _sum(col, *conds) -> int:
|
||||
stmt = select(func.coalesce(func.sum(col), 0))
|
||||
if conds:
|
||||
stmt = stmt.where(*conds)
|
||||
return db.execute(stmt).scalar_one()
|
||||
return int(db.execute(stmt).scalar_one())
|
||||
|
||||
def _user_id_set(stmt) -> set[int]:
|
||||
return {int(v) for v in db.execute(stmt).scalars().all() if v is not None}
|
||||
|
||||
# ===== 用户 =====
|
||||
by_status = dict(
|
||||
@@ -74,6 +195,223 @@ def dashboard_overview(db: Session) -> dict:
|
||||
comparison_total = _count(ComparisonRecord)
|
||||
comparison_success = _count(ComparisonRecord, ComparisonRecord.status == "success")
|
||||
success_rate = round(comparison_success / comparison_total, 4) if comparison_total else 0.0
|
||||
period_comparison_conds = (
|
||||
ComparisonRecord.created_at >= start_local,
|
||||
ComparisonRecord.created_at < end_local,
|
||||
)
|
||||
period_comparison_total = _count(ComparisonRecord, *period_comparison_conds)
|
||||
period_comparison_success = _count(
|
||||
ComparisonRecord,
|
||||
*period_comparison_conds,
|
||||
ComparisonRecord.status == "success",
|
||||
)
|
||||
period_comparison_success_rate = (
|
||||
round(period_comparison_success / period_comparison_total, 4)
|
||||
if period_comparison_total
|
||||
else 0.0
|
||||
)
|
||||
period_saved_positive_count = _count(
|
||||
ComparisonRecord,
|
||||
*period_comparison_conds,
|
||||
ComparisonRecord.status == "success",
|
||||
ComparisonRecord.saved_amount_cents > 0,
|
||||
)
|
||||
period_saved_positive_sum = _sum(
|
||||
ComparisonRecord.saved_amount_cents,
|
||||
*period_comparison_conds,
|
||||
ComparisonRecord.status == "success",
|
||||
ComparisonRecord.saved_amount_cents > 0,
|
||||
)
|
||||
period_avg_saved_cents = (
|
||||
round(period_saved_positive_sum / period_saved_positive_count)
|
||||
if period_saved_positive_count
|
||||
else None
|
||||
)
|
||||
period_avg_duration_ms = db.execute(
|
||||
select(func.avg(ComparisonRecord.total_ms)).where(
|
||||
*period_comparison_conds,
|
||||
ComparisonRecord.total_ms.is_not(None),
|
||||
ComparisonRecord.total_ms > 0,
|
||||
)
|
||||
).scalar_one()
|
||||
period_avg_duration_ms = (
|
||||
round(float(period_avg_duration_ms))
|
||||
if period_avg_duration_ms is not None
|
||||
else None
|
||||
)
|
||||
|
||||
ordered_exists = (
|
||||
select(SavingsRecord.id)
|
||||
.where(
|
||||
SavingsRecord.user_id == ComparisonRecord.user_id,
|
||||
SavingsRecord.source == "compare",
|
||||
SavingsRecord.shop_name.is_not(None),
|
||||
SavingsRecord.shop_name == ComparisonRecord.store_name,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
period_ordered_count = _count(
|
||||
ComparisonRecord,
|
||||
*period_comparison_conds,
|
||||
ComparisonRecord.store_name.is_not(None),
|
||||
ordered_exists,
|
||||
)
|
||||
|
||||
# ===== 日期窗口用户 =====
|
||||
period_new_user_ids = _user_id_set(
|
||||
select(User.id).where(User.created_at >= start_utc, User.created_at < end_utc)
|
||||
)
|
||||
login_user_ids = _user_id_set(
|
||||
select(User.id).where(User.last_login_at >= start_utc, User.last_login_at < end_utc)
|
||||
)
|
||||
compare_start_user_ids = _event_user_ids(
|
||||
db, (COMPARE_START_EVENT,), start_utc, end_utc
|
||||
)
|
||||
coupon_event_user_ids = _event_user_ids(
|
||||
db, (COUPON_START_EVENT,), start_utc, end_utc
|
||||
)
|
||||
coupon_claim_user_ids = _user_id_set(
|
||||
select(CouponPromptEngagement.user_id).where(
|
||||
CouponPromptEngagement.engage_date >= period_from,
|
||||
CouponPromptEngagement.engage_date <= period_to,
|
||||
CouponPromptEngagement.engage_type == "claim_started",
|
||||
)
|
||||
)
|
||||
period_active_user_ids = (
|
||||
login_user_ids | compare_start_user_ids | coupon_event_user_ids | coupon_claim_user_ids
|
||||
)
|
||||
period_retained_new_user_ids = period_new_user_ids & period_active_user_ids
|
||||
period_retention_rate = (
|
||||
round(len(period_retained_new_user_ids) / len(period_new_user_ids), 4)
|
||||
if period_new_user_ids
|
||||
else None
|
||||
)
|
||||
trend_points: list[dict] = []
|
||||
for cur_date in _date_range(period_from, period_to):
|
||||
day_start_utc, day_end_utc, day_start_local, day_end_local = _period_bounds(
|
||||
cur_date, cur_date
|
||||
)
|
||||
daily_comparison_conds = (
|
||||
ComparisonRecord.created_at >= day_start_local,
|
||||
ComparisonRecord.created_at < day_end_local,
|
||||
)
|
||||
daily_login_user_ids = _user_id_set(
|
||||
select(User.id).where(
|
||||
User.last_login_at >= day_start_utc,
|
||||
User.last_login_at < day_end_utc,
|
||||
)
|
||||
)
|
||||
daily_compare_start_user_ids = _event_user_ids(
|
||||
db, (COMPARE_START_EVENT,), day_start_utc, day_end_utc
|
||||
)
|
||||
daily_coupon_event_user_ids = _event_user_ids(
|
||||
db, (COUPON_START_EVENT,), day_start_utc, day_end_utc
|
||||
)
|
||||
daily_coupon_claim_user_ids = _user_id_set(
|
||||
select(CouponPromptEngagement.user_id).where(
|
||||
CouponPromptEngagement.engage_date == cur_date,
|
||||
CouponPromptEngagement.engage_type == "claim_started",
|
||||
)
|
||||
)
|
||||
trend_points.append(
|
||||
{
|
||||
"date": cur_date,
|
||||
"active_users": len(
|
||||
daily_login_user_ids
|
||||
| daily_compare_start_user_ids
|
||||
| daily_coupon_event_user_ids
|
||||
| daily_coupon_claim_user_ids
|
||||
),
|
||||
"new_users": _count(
|
||||
User,
|
||||
User.created_at >= day_start_utc,
|
||||
User.created_at < day_end_utc,
|
||||
),
|
||||
"comparisons": _count(ComparisonRecord, *daily_comparison_conds),
|
||||
}
|
||||
)
|
||||
|
||||
period_coin_conds = (
|
||||
CoinTransaction.created_at >= start_local,
|
||||
CoinTransaction.created_at < end_local,
|
||||
CoinTransaction.amount > 0,
|
||||
)
|
||||
period_reward_video_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.in_(("reward_video", "ad_reward")),
|
||||
)
|
||||
period_feed_ad_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type == "feed_ad_reward",
|
||||
)
|
||||
period_signin_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type == "signin",
|
||||
)
|
||||
period_signin_boost_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type == "signin_boost",
|
||||
)
|
||||
period_task_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.like("task_%"),
|
||||
)
|
||||
period_coupon_reward_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.in_(COUPON_REWARD_BIZ_TYPES),
|
||||
)
|
||||
period_comparison_reward_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.in_(COMPARISON_REWARD_BIZ_TYPES),
|
||||
)
|
||||
period_regular_task_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.notin_(REGULAR_TASK_EXCLUDED_BIZ_TYPES),
|
||||
)
|
||||
period_cps_orders = list(
|
||||
db.execute(
|
||||
select(CpsOrder).where(
|
||||
CpsOrder.pay_time >= start_utc,
|
||||
CpsOrder.pay_time < end_utc,
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
period_meituan_orders = [
|
||||
o for o in period_cps_orders if (o.platform or "meituan") == "meituan"
|
||||
]
|
||||
period_meituan_valid_orders = [
|
||||
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_miss_count = 0
|
||||
period_meituan_unknown_rate_count = 0
|
||||
for order in period_meituan_valid_orders:
|
||||
rate = _commission_rate_percent(order.commission_rate)
|
||||
if rate is None:
|
||||
period_meituan_unknown_rate_count += 1
|
||||
elif rate < Decimal("1"):
|
||||
period_meituan_miss_count += 1
|
||||
else:
|
||||
period_meituan_hit_count += 1
|
||||
period_meituan_hit_denominator = period_meituan_hit_count + period_meituan_miss_count
|
||||
period_meituan_hit_rate = (
|
||||
round(period_meituan_hit_count / period_meituan_hit_denominator, 4)
|
||||
if period_meituan_hit_denominator
|
||||
else None
|
||||
)
|
||||
|
||||
return {
|
||||
"users": {
|
||||
@@ -132,7 +470,73 @@ def dashboard_overview(db: Session) -> dict:
|
||||
"success": comparison_success,
|
||||
"success_rate": success_rate,
|
||||
},
|
||||
"feedback": {"new": _count(Feedback, Feedback.status.in_(("pending", "new")))},
|
||||
# CPS 收入数据源未接(referral-link 只换链接,转化/佣金未回收)→ 前端显示"待接入"。
|
||||
"cps": {"available": False, "note": "CPS 转化数据未接入(P2)"},
|
||||
"period": {
|
||||
"date_from": period_from,
|
||||
"date_to": period_to,
|
||||
"users": {
|
||||
"new": len(period_new_user_ids),
|
||||
"active": len(period_active_user_ids),
|
||||
"retained_new_users": len(period_retained_new_user_ids),
|
||||
"retention_rate": period_retention_rate,
|
||||
"retention_note": (
|
||||
"口径:登录(last_login_at)+开始比价(real_compare_start)+"
|
||||
"开始领券(real_coupon_start/claim_started),按用户去重"
|
||||
),
|
||||
},
|
||||
"comparison": {
|
||||
"total": period_comparison_total,
|
||||
"success": period_comparison_success,
|
||||
"success_rate": period_comparison_success_rate,
|
||||
"ordered": period_ordered_count,
|
||||
"average_duration_ms": period_avg_duration_ms,
|
||||
"average_saved_cents": period_avg_saved_cents,
|
||||
},
|
||||
"coins": {
|
||||
"granted_total": _sum(CoinTransaction.amount, *period_coin_conds),
|
||||
"reward_video_coin_total": period_reward_video_coin_total,
|
||||
"feed_ad_coin_total": period_feed_ad_coin_total,
|
||||
"signin_coin_total": period_signin_coin_total,
|
||||
"signin_boost_coin_total": period_signin_boost_coin_total,
|
||||
"task_coin_total": period_task_coin_total,
|
||||
"coupon_reward_coin_total": period_coupon_reward_coin_total,
|
||||
"comparison_reward_coin_total": period_comparison_reward_coin_total,
|
||||
"regular_task_coin_total": period_regular_task_coin_total,
|
||||
},
|
||||
"cash": {
|
||||
"withdraw_success_cents": _sum(
|
||||
WithdrawOrder.amount_cents,
|
||||
WithdrawOrder.status == "success",
|
||||
WithdrawOrder.created_at >= start_local,
|
||||
WithdrawOrder.created_at < end_local,
|
||||
),
|
||||
},
|
||||
"trend": trend_points,
|
||||
},
|
||||
"feedback": {
|
||||
"new": _count(Feedback, Feedback.status.in_(("pending", "new"))),
|
||||
},
|
||||
"cps": {
|
||||
"available": True,
|
||||
"note": "美团/JD CPS 读 cps_order 对账订单;淘宝佣金暂空",
|
||||
"meituan_order_count": len(period_meituan_valid_orders),
|
||||
"meituan_commission_cents": sum(
|
||||
o.commission_cents or 0 for o in period_meituan_valid_orders
|
||||
),
|
||||
"meituan_hit_count": period_meituan_hit_count,
|
||||
"meituan_miss_count": period_meituan_miss_count,
|
||||
"meituan_unknown_rate_count": period_meituan_unknown_rate_count,
|
||||
"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,6 +56,13 @@ def get_ad_revenue_report(
|
||||
"全局筛选,同时影响明细 / 合计 / 趋势"
|
||||
),
|
||||
] = None,
|
||||
app_env: Annotated[
|
||||
str | None,
|
||||
Query(
|
||||
description="prod(正式应用) / test(测试应用);不传=全部。"
|
||||
"建议正式收益报表选 prod,避免测试应用的假 eCPM 污染收益合计/平均"
|
||||
),
|
||||
] = None,
|
||||
granularity: Annotated[
|
||||
str, Query(description="day=按天 / hour=按小时(北京时间);区间>1 天建议用 day")
|
||||
] = "day",
|
||||
@@ -75,7 +82,7 @@ def get_ad_revenue_report(
|
||||
|
||||
result = ad_revenue.ad_revenue_report(
|
||||
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(),
|
||||
user_id=user_id, ad_type=ad_type, feed_scene=feed_scene,
|
||||
user_id=user_id, ad_type=ad_type, feed_scene=feed_scene, app_env=app_env,
|
||||
granularity=granularity, limit=limit, offset=offset, sort=sort,
|
||||
)
|
||||
return AdRevenueReportOut(
|
||||
@@ -89,6 +96,9 @@ def get_ad_revenue_report(
|
||||
truncated=result["truncated"],
|
||||
total_impressions=result["total_impressions"],
|
||||
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_actual_coin=result["total_actual_coin"],
|
||||
mismatch_count=result["mismatch_count"],
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""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"],
|
||||
)
|
||||
+95
-10
@@ -1,13 +1,13 @@
|
||||
"""admin CPS 分发与对账:群/活动管理 + 生成落地页短链 + 美团订单对账 + 统计。
|
||||
"""admin CPS 分发与对账:群/活动管理 + 生成落地页短链 + 联盟订单对账 + 统计。
|
||||
|
||||
平台:meituan(actId+sid 转链 + query_order 对账) / taobao(整段淘口令) / jd(链接)。
|
||||
淘宝/京东无 API → 只统计点击(咱落地页 PV/UV + 淘宝复制),对账字段显示 "-"。
|
||||
淘宝暂未接 API → 只统计点击(咱落地页 PV/UV + 淘宝复制),对账字段显示 "-"。
|
||||
群/活动管理 = operator;订单对账(涉佣金) = finance;只读列表/统计 = 登录即可。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import date as _date, datetime, time as _dt_time, timedelta, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
|
||||
@@ -34,6 +34,7 @@ from app.admin.schemas.cps import (
|
||||
from app.core import media
|
||||
from app.core.config import settings
|
||||
from app.integrations import meituan
|
||||
from app.integrations.jd_union import JdUnionError
|
||||
from app.integrations.meituan import MeituanCpsError
|
||||
from app.models.admin import AdminUser
|
||||
from app.models.cps_activity import CpsActivity
|
||||
@@ -340,24 +341,108 @@ def generate_referral_links(
|
||||
|
||||
|
||||
# ───────────── 订单对账 ─────────────
|
||||
@router.post("/orders/reconcile", response_model=CpsReconcileResult, summary="拉取美团订单对账")
|
||||
_BEIJING = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def _parse_day(value: str | None, *, field: str) -> _date | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return _date.fromisoformat(value)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=f"{field} 需为 YYYY-MM-DD") from e
|
||||
|
||||
|
||||
def _reconcile_range_to_ts(
|
||||
date_from: _date | None, date_to: _date | None, days: int
|
||||
) -> tuple[int, int]:
|
||||
if date_from is None and date_to is None:
|
||||
now = int(time.time())
|
||||
return now - days * 86400, now
|
||||
|
||||
start_day = date_from or date_to
|
||||
end_day = date_to or date_from
|
||||
if start_day is None or end_day is None:
|
||||
raise HTTPException(status_code=422, detail="日期参数不完整")
|
||||
if start_day > end_day:
|
||||
start_day, end_day = end_day, start_day
|
||||
if (end_day - start_day).days + 1 > 90:
|
||||
raise HTTPException(status_code=422, detail="美团订单查询最长 90 天")
|
||||
|
||||
start_dt = datetime.combine(start_day, _dt_time.min, tzinfo=_BEIJING)
|
||||
end_dt = datetime.combine(end_day + timedelta(days=1), _dt_time.min, tzinfo=_BEIJING)
|
||||
return int(start_dt.timestamp()), int(end_dt.timestamp())
|
||||
|
||||
|
||||
def _reconcile_range_to_bj_dt(
|
||||
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(
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("finance"))],
|
||||
db: AdminDb,
|
||||
days: Annotated[int, Query(ge=1, le=90)] = 7,
|
||||
date_from: 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,
|
||||
sid: Annotated[str | None, Query(max_length=64)] = None,
|
||||
query_time_type: Annotated[int, Query(ge=1, le=3)] = 1,
|
||||
platform: Annotated[str, Query(pattern="^(all|meituan|jd)$")] = "all",
|
||||
) -> CpsReconcileResult:
|
||||
now = int(time.time())
|
||||
parsed_from = _parse_day(date_from, field="date_from")
|
||||
parsed_to = _parse_day(date_to, field="date_to")
|
||||
result = {"fetched": 0, "inserted": 0, "updated": 0, "pages": 0}
|
||||
try:
|
||||
result = cps_repo.reconcile_orders(
|
||||
db, start_time=now - days * 86400, end_time=now, sid=sid,
|
||||
)
|
||||
if platform in {"all", "meituan"}:
|
||||
start_ts, end_ts = _reconcile_range_to_ts(parsed_from, parsed_to, days)
|
||||
mt_result = cps_repo.reconcile_orders(
|
||||
db,
|
||||
start_time=start_ts,
|
||||
end_time=end_ts,
|
||||
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:
|
||||
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(
|
||||
db, admin, action="cps.order.reconcile", target_type="cps_order", target_id=None,
|
||||
detail={"days": days, "sid": sid, **result}, ip=get_client_ip(request), commit=True,
|
||||
detail={
|
||||
"platform": platform,
|
||||
"date_from": date_from,
|
||||
"date_to": date_to,
|
||||
"days": days,
|
||||
"sid": sid,
|
||||
"query_time_type": query_time_type,
|
||||
**result,
|
||||
},
|
||||
ip=get_client_ip(request),
|
||||
commit=True,
|
||||
)
|
||||
return CpsReconcileResult(**result)
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""admin 数据大盘(只读聚合)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.admin.deps import AdminDb, get_current_admin
|
||||
from app.admin.repositories import stats
|
||||
@@ -15,5 +17,11 @@ router = APIRouter(
|
||||
|
||||
|
||||
@router.get("/overview", response_model=DashboardOverview, summary="大盘核心指标")
|
||||
def overview(db: AdminDb) -> DashboardOverview:
|
||||
return DashboardOverview.model_validate(stats.dashboard_overview(db))
|
||||
def overview(
|
||||
db: AdminDb,
|
||||
date_from: date | None = Query(None, description="北京时间自然日起始日 YYYY-MM-DD"),
|
||||
date_to: date | None = Query(None, description="北京时间自然日结束日 YYYY-MM-DD"),
|
||||
) -> DashboardOverview:
|
||||
return DashboardOverview.model_validate(
|
||||
stats.dashboard_overview(db, date_from=date_from, date_to=date_to)
|
||||
)
|
||||
|
||||
@@ -10,7 +10,12 @@ from app.admin.audit import write_audit
|
||||
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
||||
from app.admin.repositories import mutations, queries
|
||||
from app.admin.schemas.common import CursorPage, OkResponse
|
||||
from app.admin.schemas.feedback import FeedbackApproveRequest, FeedbackOut, FeedbackRejectRequest
|
||||
from app.admin.schemas.feedback import (
|
||||
FeedbackApproveRequest,
|
||||
FeedbackOut,
|
||||
FeedbackRejectRequest,
|
||||
FeedbackSummary,
|
||||
)
|
||||
from app.models.admin import AdminUser
|
||||
from app.models.feedback import Feedback
|
||||
from app.repositories import wallet as wallet_repo
|
||||
@@ -31,6 +36,8 @@ def _ensure_pending(fb: Feedback) -> None:
|
||||
def list_feedbacks(
|
||||
db: AdminDb,
|
||||
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,
|
||||
content: Annotated[str | None, Query(max_length=100)] = None,
|
||||
created_from: Annotated[datetime | None, Query()] = None,
|
||||
@@ -43,6 +50,7 @@ def list_feedbacks(
|
||||
items, next_cursor, total = queries.list_feedbacks(
|
||||
db,
|
||||
status=status,
|
||||
source=source,
|
||||
user_id=user_id,
|
||||
content=content,
|
||||
created_from=created_from,
|
||||
@@ -59,6 +67,11 @@ 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="标记反馈已处理")
|
||||
def handle_feedback(
|
||||
feedback_id: int,
|
||||
@@ -91,6 +104,7 @@ def approve_feedback(
|
||||
status="adopted",
|
||||
reward_coins=payload.reward_coins,
|
||||
review_note=payload.note,
|
||||
admin_reply=payload.reply,
|
||||
reviewed_by_admin_id=admin.id,
|
||||
commit=False,
|
||||
)
|
||||
@@ -113,6 +127,7 @@ def approve_feedback(
|
||||
"after": "adopted",
|
||||
"reward_coins": payload.reward_coins,
|
||||
"note": payload.note,
|
||||
"reply": payload.reply,
|
||||
},
|
||||
ip=get_client_ip(request),
|
||||
commit=False,
|
||||
@@ -142,6 +157,7 @@ def reject_feedback(
|
||||
status="rejected",
|
||||
reject_reason=payload.reason,
|
||||
review_note=payload.note,
|
||||
admin_reply=payload.reply,
|
||||
reviewed_by_admin_id=admin.id,
|
||||
commit=False,
|
||||
)
|
||||
@@ -156,6 +172,7 @@ def reject_feedback(
|
||||
"after": "rejected",
|
||||
"reason": payload.reason,
|
||||
"note": payload.note,
|
||||
"reply": payload.reply,
|
||||
},
|
||||
ip=get_client_ip(request),
|
||||
commit=False,
|
||||
|
||||
@@ -37,11 +37,14 @@ def list_price_reports(
|
||||
db: AdminDb,
|
||||
status: Annotated[str | 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,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[PriceReportOut]:
|
||||
items, next_cursor, total = queries.list_price_reports(
|
||||
db, status=status, user_id=user_id, limit=limit, cursor=cursor,
|
||||
db, status=status, user_id=user_id,
|
||||
sort_by=sort_by, sort_order=sort_order, limit=limit, cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
items=[PriceReportOut.model_validate(r) for r in items],
|
||||
|
||||
+19
-11
@@ -210,22 +210,28 @@ def grant_user_cash(
|
||||
db: AdminDb,
|
||||
) -> OkResponse:
|
||||
"""给指定用户增/减或设值现金(分)。delta:正=发放、负=扣减;set:直接设为目标值。
|
||||
account=coin_cash(金币兑现金)/ invite_cash(邀请奖励金):两本账物理隔离、各调各的。
|
||||
主要用于让无现金用户直接测试提现。"""
|
||||
user = user_repo.get_user_by_id(db, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
# set=设为目标值:读当前余额算差值,仍复用 grant_cash 写一笔流水(沿用原子/审计/扣负保护)
|
||||
# 按目标账户选「余额字段 + 变动入口」(grant_invite_cash 与 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.amount_cents < 0:
|
||||
raise HTTPException(status_code=400, detail="目标现金值不能为负")
|
||||
raise HTTPException(status_code=400, detail=f"目标{acct_label}值不能为负")
|
||||
# lock=True:锁账户行,防连点/并发各读同一 before 算同一 delta 双写,余额错位
|
||||
before = wallet_repo.get_or_create_account(
|
||||
db, user_id, commit=False, lock=True
|
||||
).cash_balance_cents
|
||||
acc_locked = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True)
|
||||
before = getattr(acc_locked, balance_attr)
|
||||
delta = body.amount_cents - before
|
||||
if delta == 0:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"当前现金已为 {body.amount_cents} 分,无需调整"
|
||||
status_code=400, detail=f"当前{acct_label}已为 {body.amount_cents} 分,无需调整"
|
||||
)
|
||||
else:
|
||||
if body.amount_cents == 0:
|
||||
@@ -234,18 +240,20 @@ def grant_user_cash(
|
||||
# 负数扣减时不允许扣成负余额(运营误操作保护);lock=True 防并发扣穿
|
||||
if delta < 0:
|
||||
acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True)
|
||||
if acc_now.cash_balance_cents + delta < 0:
|
||||
if getattr(acc_now, balance_attr) + delta < 0:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"扣减后现金为负(当前余额 {acc_now.cash_balance_cents} 分)"
|
||||
status_code=400,
|
||||
detail=f"扣减后{acct_label}为负(当前余额 {getattr(acc_now, balance_attr)} 分)",
|
||||
)
|
||||
biz_type = "admin_grant" if delta > 0 else "admin_deduct"
|
||||
# grant_cash 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕)
|
||||
acc, _ = wallet_repo.grant_cash(
|
||||
# grant 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕)
|
||||
acc, _ = grant_fn(
|
||||
db, user_id, delta, biz_type=biz_type, remark=f"admin:{body.reason}"[:128],
|
||||
)
|
||||
detail = {
|
||||
"account": body.account,
|
||||
"amount_cents": delta,
|
||||
"balance_after_cents": acc.cash_balance_cents,
|
||||
"balance_after_cents": getattr(acc, balance_attr),
|
||||
"reason": body.reason,
|
||||
}
|
||||
if body.mode == "set":
|
||||
|
||||
@@ -50,6 +50,8 @@ def list_withdraws(
|
||||
db: AdminDb,
|
||||
user_id: Annotated[int | 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,
|
||||
date_from: Annotated[datetime | None, Query()] = None,
|
||||
date_to: Annotated[datetime | None, Query()] = None,
|
||||
@@ -69,6 +71,7 @@ def list_withdraws(
|
||||
db,
|
||||
user_id=user_id,
|
||||
status=status,
|
||||
source=source,
|
||||
keyword=keyword,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
|
||||
@@ -44,7 +44,13 @@ class AdRevenueDaily(BaseModel):
|
||||
|
||||
date: str = Field(..., description="北京时间 YYYY-MM-DD")
|
||||
impressions: int = Field(..., description="当天展示条数合计")
|
||||
revenue_yuan: float = Field(..., description="当天预估收益合计(元)")
|
||||
revenue_yuan: float = Field(..., description="当天客户端预估收益合计(元;eCPM 折算)")
|
||||
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="当天应发金币合计")
|
||||
actual_coin: int = Field(..., description="当天实发金币合计")
|
||||
|
||||
@@ -88,6 +94,11 @@ class AdRevenueRow(BaseModel):
|
||||
impressions: int = Field(..., description="本行展示条数:有展示=1 / 纯发奖=0(供日汇总、趋势图复用)")
|
||||
ecpm: str | None = Field(None, description="eCPM 原始值(分/千次);展示行取展示值,纯发奖行取发奖采用值")
|
||||
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…);纯发奖行为空")
|
||||
slot_id: str | None = Field(None, description="底层 mediation rit(非我们配置的广告位 ID);纯发奖行为空")
|
||||
# ── 发奖侧 ──
|
||||
@@ -100,6 +111,15 @@ class AdRevenueRow(BaseModel):
|
||||
None,
|
||||
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):
|
||||
@@ -123,7 +143,21 @@ class AdRevenueReportOut(BaseModel):
|
||||
total: int = Field(..., description="广告事件总数(全量,不受分页影响;= 当前筛选下的分页总条数)")
|
||||
truncated: bool = Field(..., description="当前页之后是否还有更多事件(len(events) > offset + limit)")
|
||||
total_impressions: int = Field(..., description="全量展示条数合计")
|
||||
total_revenue_yuan: float = Field(..., description="全量收益合计(元)")
|
||||
total_revenue_yuan: float = Field(..., description="全量客户端预估收益合计(元;eCPM 折算)")
|
||||
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_actual_coin: int = Field(..., description="全量实发金币合计")
|
||||
mismatch_count: int = Field(..., description="应发≠实发的发奖条数(=0 说明全部按公式发放)")
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""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() 展示。
|
||||
|
||||
平台:meituan(actId+sid 转链对账) / taobao(淘口令,只统计点击) / jd(链接,只统计点击)。
|
||||
对账类字段对淘宝/京东为 None → 前端显示 "-"(无法对账)。
|
||||
平台:meituan(actId+sid 转链对账) / taobao(淘口令,只统计点击) / jd(链接 + 订单 API 对账)。
|
||||
对账类字段对淘宝为 None → 前端显示 "-"(暂未对账)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -116,15 +116,22 @@ class CpsOrderOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
platform: str = "meituan"
|
||||
order_id: str
|
||||
external_order_id: str | None = None
|
||||
external_row_id: str | None = None
|
||||
sid: str | None = None
|
||||
act_id: str | None = None
|
||||
pay_price_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
|
||||
mt_status: str | None = None
|
||||
jd_valid_code: str | None = None
|
||||
invalid_reason: str | None = None
|
||||
product_name: str | None = None
|
||||
settle_month: str | None = None
|
||||
pay_time: datetime | None = None
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""admin 大盘 schemas(对应 stats.dashboard_overview 的嵌套结构)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
@@ -38,6 +40,56 @@ class DashboardComparison(BaseModel):
|
||||
success_rate: float
|
||||
|
||||
|
||||
class DashboardPeriodUsers(BaseModel):
|
||||
new: int
|
||||
active: int
|
||||
retained_new_users: int
|
||||
retention_rate: float | None = None
|
||||
retention_note: str
|
||||
|
||||
|
||||
class DashboardPeriodComparison(BaseModel):
|
||||
total: int
|
||||
success: int
|
||||
success_rate: float
|
||||
ordered: int
|
||||
average_duration_ms: int | None = None
|
||||
average_saved_cents: int | None = None
|
||||
|
||||
|
||||
class DashboardPeriodCoins(BaseModel):
|
||||
granted_total: int
|
||||
reward_video_coin_total: int = 0
|
||||
feed_ad_coin_total: int = 0
|
||||
signin_coin_total: int = 0
|
||||
signin_boost_coin_total: int = 0
|
||||
task_coin_total: int = 0
|
||||
coupon_reward_coin_total: int = 0
|
||||
comparison_reward_coin_total: int = 0
|
||||
regular_task_coin_total: int = 0
|
||||
|
||||
|
||||
class DashboardPeriodCash(BaseModel):
|
||||
withdraw_success_cents: int
|
||||
|
||||
|
||||
class DashboardTrendPoint(BaseModel):
|
||||
date: date
|
||||
active_users: int
|
||||
new_users: int
|
||||
comparisons: int
|
||||
|
||||
|
||||
class DashboardPeriod(BaseModel):
|
||||
date_from: date
|
||||
date_to: date
|
||||
users: DashboardPeriodUsers
|
||||
comparison: DashboardPeriodComparison
|
||||
coins: DashboardPeriodCoins
|
||||
cash: DashboardPeriodCash
|
||||
trend: list[DashboardTrendPoint] = []
|
||||
|
||||
|
||||
class DashboardFeedback(BaseModel):
|
||||
new: int
|
||||
|
||||
@@ -45,6 +97,17 @@ class DashboardFeedback(BaseModel):
|
||||
class DashboardCps(BaseModel):
|
||||
available: bool
|
||||
note: str
|
||||
meituan_order_count: int = 0
|
||||
meituan_commission_cents: int = 0
|
||||
meituan_hit_count: int = 0
|
||||
meituan_miss_count: int = 0
|
||||
meituan_unknown_rate_count: int = 0
|
||||
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):
|
||||
@@ -52,5 +115,6 @@ class DashboardOverview(BaseModel):
|
||||
coins: DashboardCoins
|
||||
cash: DashboardCash
|
||||
comparison: DashboardComparison
|
||||
period: DashboardPeriod
|
||||
feedback: DashboardFeedback
|
||||
cps: DashboardCps
|
||||
|
||||
@@ -24,7 +24,9 @@ class DeviceLivenessItem(BaseModel):
|
||||
device_model: str | None = None # 由 device_id 解析(device_<机型>_<hash>);非 DB 列
|
||||
platform: str
|
||||
app_version: str | None = None
|
||||
registration_id: str | None = None # 非空 = 拿到极光 token、可推送
|
||||
registration_id: str | None = None # 旧极光字段,仅兼容历史数据
|
||||
push_vendor: str | None = None
|
||||
push_token: str | None = None
|
||||
|
||||
ever_protected: bool # 是否开过无障碍(=该设备对功能有意义)
|
||||
first_protected_at: datetime | None = None # 首次开无障碍时刻(老设备为 null)
|
||||
|
||||
@@ -15,14 +15,28 @@ class FeedbackOut(BaseModel):
|
||||
user_id: int
|
||||
content: str
|
||||
contact: str
|
||||
# 反馈来源入口:profile(「我的」页)/ comparison(比价结果页)——admin 展示/筛选「反馈类型」
|
||||
source: str = "profile"
|
||||
# 比价反馈的问题场景(找错商品/优惠不对…);普通反馈为 None
|
||||
scene: str | None = None
|
||||
images: list[str] | None = None
|
||||
status: str
|
||||
reject_reason: str | None = None
|
||||
reward_coins: int | None = None
|
||||
review_note: str | None = None
|
||||
# 运营给用户的回复留言(用户端可见,采纳/未采纳都可填)
|
||||
admin_reply: str | None = None
|
||||
reviewed_by_admin_id: int | None = None
|
||||
reviewed_at: datetime | None = None
|
||||
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):
|
||||
@@ -31,9 +45,20 @@ class FeedbackApproveRequest(BaseModel):
|
||||
le=FEEDBACK_REWARD_MAX_COINS,
|
||||
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):
|
||||
reason: str = Field(min_length=1, 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,6 +31,17 @@ class PriceReportOut(BaseModel):
|
||||
reward_coins: int | None = None
|
||||
reviewed_at: datetime | None = None
|
||||
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):
|
||||
|
||||
@@ -17,6 +17,7 @@ class AdminUserListItem(BaseModel):
|
||||
status: str
|
||||
debug_trace_enabled: bool = False
|
||||
wechat_openid: str | None = None
|
||||
wechat_nickname: str | None = None
|
||||
created_at: datetime
|
||||
last_login_at: datetime
|
||||
|
||||
@@ -29,6 +30,7 @@ class AdminUserOverview(BaseModel):
|
||||
user: AdminUserListItem
|
||||
coin_balance: int
|
||||
cash_balance_cents: int
|
||||
invite_cash_balance_cents: int # 邀请奖励金余额(与 cash_balance_cents 物理隔离)
|
||||
total_coin_earned: int
|
||||
comparison_total: int
|
||||
comparison_success: int
|
||||
@@ -88,6 +90,11 @@ class GrantCoinsRequest(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(
|
||||
"delta", description="delta=增减(amount_cents 为变动量) / set=设为(amount_cents 为目标值,须≥0)"
|
||||
)
|
||||
|
||||
@@ -41,6 +41,8 @@ class WithdrawOrderOut(BaseModel):
|
||||
user_id: int
|
||||
out_bill_no: str
|
||||
amount_cents: int
|
||||
# 提现类型:coin_cash(福利页提现,金币兑换的现金)/ invite_cash(邀请提现,邀请奖励金)
|
||||
source: str = "coin_cash"
|
||||
user_name: str | None = None # 提现实名(审核核对 + 打款用)
|
||||
status: str
|
||||
wechat_state: str | None = None
|
||||
|
||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, status
|
||||
@@ -21,6 +22,7 @@ from app.repositories import launch_confirm_sample as repo
|
||||
from app.schemas.launch_confirm_sample import (
|
||||
LaunchConfirmSampleIn,
|
||||
LaunchConfirmSampleOut,
|
||||
LaunchConfirmSampleRow,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.internal.launch_confirm")
|
||||
@@ -61,3 +63,34 @@ def report_launch_confirm_sample(
|
||||
payload.exec_success, payload.trace_id,
|
||||
)
|
||||
return LaunchConfirmSampleOut(id=sid)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/launch-confirm-samples",
|
||||
response_model=list[LaunchConfirmSampleRow],
|
||||
summary="启动确认窗兜底样本列表(沉淀脚本 distill 读; server→server, 不走 JWT)",
|
||||
)
|
||||
def list_launch_confirm_samples(
|
||||
db: DbSession,
|
||||
x_internal_secret: Annotated[str | None, Header()] = None,
|
||||
exec_success: bool | None = None,
|
||||
host_package: str | None = None,
|
||||
since_days: int | None = None,
|
||||
limit: int = 1000,
|
||||
) -> list[LaunchConfirmSampleRow]:
|
||||
"""读样本供 pricebot 的 distill_launch_confirm.py 聚合沉淀回 PROFILES。
|
||||
|
||||
与上报端点同一把共享密钥;exec_success/host_package/since_days 均可选,limit 默认 1000。
|
||||
"""
|
||||
_check_secret(x_internal_secret)
|
||||
since = None
|
||||
if since_days and since_days > 0:
|
||||
since = datetime.now(timezone.utc) - timedelta(days=since_days)
|
||||
rows = repo.list_samples(
|
||||
db,
|
||||
exec_success=exec_success,
|
||||
host_package=host_package,
|
||||
since=since,
|
||||
limit=limit,
|
||||
)
|
||||
return [LaunchConfirmSampleRow.model_validate(r) for r in rows]
|
||||
|
||||
@@ -26,6 +26,7 @@ import httpx
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.pricebot_client import get_pricebot_client
|
||||
from app.core.pricebot_router import pick_pricebot
|
||||
|
||||
logger = logging.getLogger("shagua.compare")
|
||||
@@ -65,10 +66,10 @@ async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(
|
||||
url, content=raw, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
client = get_pricebot_client()
|
||||
resp = await client.post(
|
||||
url, content=raw, headers={"Content-Type": "application/json"}, timeout=timeout
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
logger.error("[pricebot] request failed: %s", e)
|
||||
raise HTTPException(
|
||||
|
||||
+34
-4
@@ -20,6 +20,7 @@ from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core.config import settings
|
||||
from app.core.pricebot_client import get_pricebot_client
|
||||
from app.core.pricebot_router import pick_pricebot
|
||||
from app.db.session import SessionLocal
|
||||
from app.repositories import coupon_state as coupon_repo
|
||||
@@ -28,6 +29,7 @@ from app.schemas.coupon_state import (
|
||||
CouponPromptDismissIn,
|
||||
CouponPromptShouldShowOut,
|
||||
CouponPromptShownIn,
|
||||
CouponSessionIn,
|
||||
CouponStatsOut,
|
||||
)
|
||||
|
||||
@@ -141,10 +143,10 @@ async def coupon_step(
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(
|
||||
url, content=raw, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
client = get_pricebot_client()
|
||||
resp = await client.post(
|
||||
url, content=raw, headers={"Content-Type": "application/json"}, timeout=timeout
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
logger.error("[pricebot] request failed: %s", e)
|
||||
raise HTTPException(
|
||||
@@ -193,6 +195,34 @@ async def coupon_step(
|
||||
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)")
|
||||
def coupon_prompt_shown(payload: CouponPromptShownIn, db: DbSession) -> dict[str, bool]:
|
||||
"""客户端弹出引导窗那刻调 → 记一条今日 engagement(shown),今天**这个 App** 不再自动弹。
|
||||
|
||||
+93
-4
@@ -1,19 +1,22 @@
|
||||
"""设备注册 / 心跳 endpoint(无障碍保护存活检测)。
|
||||
|
||||
路由前缀 /api/v1/device,需 Bearer 鉴权(设备绑登录用户)。
|
||||
POST /register 注册设备 / 更新 registration_id(App 前台、拿到 push token 时调)
|
||||
POST /register 注册设备 / 更新厂商 push token(App 前台、拿到 push token 时调)
|
||||
POST /heartbeat 上报心跳(无障碍服务存活时周期调,刷新存活)
|
||||
POST /push-test 开发验收:延迟发送厂商通道测试推送
|
||||
|
||||
后端 heartbeat_monitor_worker 据此发现心跳超时的设备并极光推送告警。
|
||||
后端 heartbeat_monitor_worker 据此发现心跳超时的设备并厂商直推告警。
|
||||
见 spec: spec/accessibility-liveness-push.md。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, status
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.integrations import vendor_push
|
||||
from app.repositories import device as device_repo
|
||||
from app.schemas.device import (
|
||||
DeviceOut,
|
||||
@@ -22,6 +25,8 @@ from app.schemas.device import (
|
||||
LivenessAckRequest,
|
||||
LivenessOut,
|
||||
OkResponse,
|
||||
PushTestOut,
|
||||
PushTestRequest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.device")
|
||||
@@ -29,6 +34,37 @@ logger = logging.getLogger("shagua.device")
|
||||
router = APIRouter(prefix="/api/v1/device", tags=["device"])
|
||||
|
||||
|
||||
def _send_push_test_after_delay(
|
||||
push_vendor: str,
|
||||
push_token: str,
|
||||
delay_seconds: int,
|
||||
user_id: int,
|
||||
device_id: str,
|
||||
) -> None:
|
||||
if delay_seconds > 0:
|
||||
time.sleep(delay_seconds)
|
||||
try:
|
||||
vendor_push.send_accessibility_disabled(
|
||||
push_vendor,
|
||||
push_token,
|
||||
title="测试推送",
|
||||
alert="这是一条厂商通道测试推送。收到它说明 App 被划掉后仍可通过系统通知栏触达。",
|
||||
)
|
||||
logger.info(
|
||||
"push test sent user_id=%d device_id=%s delay=%ds",
|
||||
user_id,
|
||||
device_id,
|
||||
delay_seconds,
|
||||
)
|
||||
except vendor_push.VendorPushError as e:
|
||||
logger.warning(
|
||||
"push test failed user_id=%d device_id=%s error=%s",
|
||||
user_id,
|
||||
device_id,
|
||||
e,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/register", response_model=DeviceOut, summary="注册设备/更新推送token")
|
||||
def register_device(
|
||||
req: DeviceRegisterRequest,
|
||||
@@ -40,13 +76,17 @@ def register_device(
|
||||
user_id=user.id,
|
||||
device_id=req.device_id,
|
||||
registration_id=req.registration_id,
|
||||
push_vendor=req.push_vendor,
|
||||
push_token=req.push_token,
|
||||
platform=req.platform,
|
||||
app_version=req.app_version,
|
||||
)
|
||||
logger.info(
|
||||
"device register user_id=%d device_id=%s reg=%s",
|
||||
"device register user_id=%d device_id=%s vendor=%s token=%s legacy_reg=%s",
|
||||
user.id,
|
||||
req.device_id,
|
||||
req.push_vendor,
|
||||
bool(req.push_token),
|
||||
bool(req.registration_id),
|
||||
)
|
||||
return DeviceOut.model_validate(device)
|
||||
@@ -64,10 +104,59 @@ def report_heartbeat(
|
||||
device_id=req.device_id,
|
||||
accessibility_enabled=req.accessibility_enabled,
|
||||
registration_id=req.registration_id,
|
||||
push_vendor=req.push_vendor,
|
||||
push_token=req.push_token,
|
||||
)
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.post("/push-test", response_model=PushTestOut, summary="延迟发送厂商通道测试推送")
|
||||
def request_push_test(
|
||||
req: PushTestRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
) -> PushTestOut:
|
||||
"""开发验收用:App 内点一次,服务端延迟发厂商直推,验证离线通道。"""
|
||||
push_vendor = req.push_vendor.strip() if req.push_vendor else None
|
||||
push_token = req.push_token.strip() if req.push_token else None
|
||||
if push_vendor and push_token:
|
||||
device_repo.register_or_update(
|
||||
db,
|
||||
user_id=user.id,
|
||||
device_id=req.device_id,
|
||||
registration_id=req.registration_id,
|
||||
push_vendor=push_vendor,
|
||||
push_token=push_token,
|
||||
)
|
||||
else:
|
||||
device = device_repo.get_device(db, user_id=user.id, device_id=req.device_id)
|
||||
push_vendor = device.push_vendor if device is not None else None
|
||||
push_token = device.push_token if device is not None else None
|
||||
|
||||
if not push_vendor or not push_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="push vendor token not ready",
|
||||
)
|
||||
|
||||
background_tasks.add_task(
|
||||
_send_push_test_after_delay,
|
||||
push_vendor,
|
||||
push_token,
|
||||
req.delay_seconds,
|
||||
user.id,
|
||||
req.device_id,
|
||||
)
|
||||
logger.info(
|
||||
"push test scheduled user_id=%d device_id=%s delay=%ds",
|
||||
user.id,
|
||||
req.device_id,
|
||||
req.delay_seconds,
|
||||
)
|
||||
return PushTestOut(delay_seconds=req.delay_seconds, has_push_token=True)
|
||||
|
||||
|
||||
@router.get("/liveness", response_model=LivenessOut, summary="查询本机掉线告警(后置检测)")
|
||||
def get_liveness(
|
||||
device_id: str,
|
||||
|
||||
+31
-1
@@ -31,7 +31,18 @@ router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"])
|
||||
_MAX_IMAGES = 6
|
||||
_CONTENT_MAX = 200
|
||||
_CONTACT_MAX = 128
|
||||
_SCENE_MAX = 32
|
||||
_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:
|
||||
@@ -46,10 +57,12 @@ def _record_out(fb) -> FeedbackRecordOut:
|
||||
return FeedbackRecordOut(
|
||||
id=fb.id,
|
||||
content=fb.content,
|
||||
scene=getattr(fb, "scene", None),
|
||||
images=fb.images or [],
|
||||
status=_app_status(fb.status),
|
||||
reject_reason=getattr(fb, "reject_reason", None),
|
||||
reward_coins=getattr(fb, "reward_coins", None),
|
||||
admin_reply=getattr(fb, "admin_reply", None),
|
||||
created_at=fb.created_at,
|
||||
)
|
||||
|
||||
@@ -61,6 +74,16 @@ async def submit_feedback(
|
||||
content: str = Form(...),
|
||||
# 原型改版后客户端不再采集联系方式;保留字段以兼容旧端 + 后续可能复用,默认空串。
|
||||
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=[]),
|
||||
) -> FeedbackOut:
|
||||
content = content.strip()
|
||||
@@ -71,6 +94,8 @@ async def submit_feedback(
|
||||
raise HTTPException(status_code=400, detail="反馈内容过长")
|
||||
if len(contact) > _CONTACT_MAX:
|
||||
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]
|
||||
if len(files) > _MAX_IMAGES:
|
||||
@@ -86,8 +111,13 @@ async def submit_feedback(
|
||||
|
||||
fb = feedback_repo.create_feedback(
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""消息通知中心 endpoint(PRD《消息通知中心》)。
|
||||
|
||||
路由前缀 `/api/v1/notifications`,需 Bearer 鉴权(消息按用户隔离)。
|
||||
GET / 消息列表(分页;全列表时间倒序,不分组——PRD 原文的分组已取消)
|
||||
GET /unread-count 未读总数(首页铃铛角标)
|
||||
POST /read 标记已读({ids:[...]} 单条/多条 或 {all:true} 全量清零)
|
||||
|
||||
⚠️ 虚拟数据阶段:数据来自 repositories/notification_mock.py 内存 mock(重启复位,
|
||||
每个用户一套覆盖全部 13 种类型的固定样例)。接真实数据时只换 repository 实现。
|
||||
|
||||
⚠️ 字段命名:本组接口对外为 **camelCase**(sentAt / isRead / pageSize…,PRD 前端契约),
|
||||
详见 schemas/notification.py 顶部说明。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from app.api.deps import CurrentUser
|
||||
from app.core import notification_catalog as catalog
|
||||
from app.repositories import notification_mock as mock_repo
|
||||
from app.schemas.notification import (
|
||||
InfoRow,
|
||||
MarkReadOut,
|
||||
MarkReadRequest,
|
||||
NotificationItem,
|
||||
NotificationListOut,
|
||||
UnreadCountOut,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.notifications")
|
||||
|
||||
router = APIRouter(prefix="/api/v1/notifications", tags=["notifications"])
|
||||
|
||||
|
||||
def _to_item(n: mock_repo.MockNotification) -> NotificationItem:
|
||||
"""mock 记录 + 类型静态目录 → 接口出参。"""
|
||||
ntype = catalog.get_type(n.type_key)
|
||||
return NotificationItem(
|
||||
id=n.id,
|
||||
category=ntype.category,
|
||||
category_label=catalog.category_label(ntype.category),
|
||||
type=ntype.key,
|
||||
card_style=ntype.card_style,
|
||||
title=ntype.card_title,
|
||||
coins=n.coins,
|
||||
cash_cents=n.cash_cents,
|
||||
cash_yuan=mock_repo.cash_yuan(n.cash_cents),
|
||||
info_rows=[InfoRow(**row) for row in n.info_rows],
|
||||
action_text=ntype.action_text,
|
||||
extra=n.extra,
|
||||
sent_at=n.sent_at,
|
||||
is_read=n.is_read,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=NotificationListOut, summary="消息列表(分页)")
|
||||
def list_notifications(
|
||||
user: CurrentUser,
|
||||
page: int = Query(default=1, ge=1, description="页码,1 起"),
|
||||
page_size: int = Query(
|
||||
default=20, ge=1, le=100, alias="pageSize", description="每页条数,默认 20,最大 100"
|
||||
),
|
||||
) -> NotificationListOut:
|
||||
"""通知中心消息列表。
|
||||
|
||||
- 排序服务端已做好:**全列表按时间倒序**(最新在前,不做分类分组;PRD §1 的
|
||||
"按分类分组"为笔误,已与需求方确认取消),前端按返回顺序渲染即可。
|
||||
- 每条的字段构成与各版式说明见 NotificationItem schema。
|
||||
- 响应同时带 unreadCount,进页面时可顺手刷新角标。
|
||||
- 虚拟数据阶段:首次请求生成一套固定样例(13 类型全覆盖 + 今天/昨天/当年/跨年
|
||||
四种时间 + 已读未读混合),已读状态在服务端内存维护,重启服务复位。
|
||||
"""
|
||||
items, total, unread = mock_repo.list_notifications(user.id, page=page, page_size=page_size)
|
||||
return NotificationListOut(
|
||||
items=[_to_item(n) for n in items],
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
has_more=page * page_size < total,
|
||||
unread_count=unread,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/unread-count", response_model=UnreadCountOut, summary="未读总数(铃铛角标)")
|
||||
def get_unread_count(user: CurrentUser) -> UnreadCountOut:
|
||||
"""首页铃铛角标数据源。刷新时机(PRD §4):进入首页时、从通知中心/其他页面返回首页时。
|
||||
|
||||
- count:精确未读条数;
|
||||
- badgeText:直接可展示的角标文案——超过 99 返回 "99+",等于 0 返回 null(隐藏整个角标)。
|
||||
"""
|
||||
count = mock_repo.unread_count(user.id)
|
||||
badge = None if count == 0 else ("99+" if count > 99 else str(count))
|
||||
return UnreadCountOut(count=count, badge_text=badge)
|
||||
|
||||
|
||||
@router.post("/read", response_model=MarkReadOut, summary="标记已读(单条/多条/全量)")
|
||||
def mark_read(req: MarkReadRequest, user: CurrentUser) -> MarkReadOut:
|
||||
"""红点消除(PRD §4),两种调用模式:
|
||||
|
||||
1. `{"ids": [90001]}` —— 点击某张消息卡片(无论点击后是跳转/弹窗/无动作都算已读);
|
||||
用户点击 push 直达落地页时,客户端也用它把对应站内消息同步置读(push extras 里带
|
||||
notificationId);
|
||||
2. `{"all": true}` —— 进入通知中心自动清零(只是浏览列表就消红点,无需逐条点击)。
|
||||
|
||||
幂等:不存在或已读的 id 忽略;重复调用 markedCount 为 0、不报错。
|
||||
响应带 unreadCount(处理后剩余未读),可直接刷新铃铛角标。
|
||||
"""
|
||||
if not req.all and not req.ids:
|
||||
raise HTTPException(status_code=400, detail="ids 与 all 至少传一个:{ids:[...]} 或 {all:true}")
|
||||
marked, unread = mock_repo.mark_read(user.id, ids=req.ids, mark_all=req.all)
|
||||
logger.info(
|
||||
"notifications read user_id=%d mode=%s marked=%d unread_left=%d",
|
||||
user.id,
|
||||
"all" if req.all else f"ids×{len(req.ids or [])}",
|
||||
marked,
|
||||
unread,
|
||||
)
|
||||
return MarkReadOut(ok=True, marked_count=marked, unread_count=unread)
|
||||
@@ -0,0 +1,186 @@
|
||||
"""厂商推送 测试/联调 endpoint。
|
||||
|
||||
路由前缀 `/api/v1/push`,需 Bearer 鉴权。围绕「消息中心 13 类通知的厂商直推」提供三件套:
|
||||
GET /vendors 5 个厂商(荣耀/华为/小米/OPPO/vivo)服务端凭据配置状态,缺哪些键一目了然
|
||||
GET /templates 13 种通知类型的 push 标题/正文模板 + PRD 示例渲染效果
|
||||
POST /test 测试发送:默认 mock(不真调厂商 API,回显渲染结果);mock=false 真发到手机
|
||||
|
||||
与 `/api/v1/device/push-test`(无障碍召回通道的延迟自测)互补:本组面向消息中心 13 类
|
||||
push 的文案/参数/厂商通道联调。真实业务触发(奖励过期、提现回执等)接入后走
|
||||
integrations.vendor_push.send_notification,与本测试端点同一条发送链路。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core import notification_catalog as catalog
|
||||
from app.integrations import vendor_push
|
||||
from app.repositories import device as device_repo
|
||||
from app.repositories import notification_mock as mock_repo
|
||||
from app.schemas.push import (
|
||||
PushTemplateOut,
|
||||
PushTemplatesOut,
|
||||
PushTestOut,
|
||||
PushTestRequest,
|
||||
PushVendorsOut,
|
||||
PushVendorStatus,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.push")
|
||||
|
||||
router = APIRouter(prefix="/api/v1/push", tags=["push"])
|
||||
|
||||
# /vendors 的展示顺序(荣耀/华为/小米/OPPO/vivo)
|
||||
_VENDOR_ORDER = ("honor", "huawei", "xiaomi", "oppo", "vivo")
|
||||
|
||||
_GENERIC_TEST_TITLE = "傻瓜比价测试推送"
|
||||
_GENERIC_TEST_BODY = "这是一条{label}通道的测试推送,收到说明服务端 → {label}厂商通道已打通。"
|
||||
|
||||
|
||||
@router.get("/vendors", response_model=PushVendorsOut, summary="厂商推送配置状态")
|
||||
def vendor_status(user: CurrentUser) -> PushVendorsOut:
|
||||
"""检查 5 个厂商的服务端推送凭据是否配齐(读 .env,不打厂商接口)。
|
||||
|
||||
missingKeys 列出的即还需要在 .env 里补的配置键;全空说明该厂商随时可真发。
|
||||
mock 测试(POST /test 默认模式)不依赖任何凭据。
|
||||
"""
|
||||
return PushVendorsOut(
|
||||
vendors=[
|
||||
PushVendorStatus(
|
||||
vendor=v,
|
||||
label=vendor_push.VENDOR_LABELS[v],
|
||||
configured=not vendor_push.missing_settings(v),
|
||||
missing_keys=vendor_push.missing_settings(v),
|
||||
)
|
||||
for v in _VENDOR_ORDER
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.get("/templates", response_model=PushTemplatesOut, summary="13 类通知的 push 模板预览")
|
||||
def push_templates(user: CurrentUser) -> PushTemplatesOut:
|
||||
"""PRD §5 的 13 条 push 文案模板 + 用示例值渲染后的效果,联调对文案用。
|
||||
|
||||
标题固定(≤11 字不带变量);正文里 {var} 为变量,POST /test 的 vars 字段可覆盖。
|
||||
"""
|
||||
templates: list[PushTemplateOut] = []
|
||||
for key, ntype in catalog.TYPES.items():
|
||||
title, body_sample = catalog.render_push(key)
|
||||
templates.append(
|
||||
PushTemplateOut(
|
||||
type=key,
|
||||
category=ntype.category,
|
||||
category_label=catalog.category_label(ntype.category),
|
||||
card_style=ntype.card_style,
|
||||
push_title=title,
|
||||
push_body_sample=body_sample,
|
||||
push_body_template=ntype.push_body_template,
|
||||
variables=catalog.push_variable_names(key),
|
||||
sample_vars=ntype.sample_vars,
|
||||
)
|
||||
)
|
||||
return PushTemplatesOut(templates=templates)
|
||||
|
||||
|
||||
@router.post("/test", response_model=PushTestOut, summary="测试发送厂商推送(默认 mock)")
|
||||
def send_test_push(req: PushTestRequest, user: CurrentUser, db: DbSession) -> PushTestOut:
|
||||
"""向指定厂商 token(或本用户已注册设备)发一条测试 push。
|
||||
|
||||
- **mock=true(默认)**:不真调厂商 API——校验参数、渲染文案后原样返回,并在
|
||||
missingKeys 里提示真发前还缺哪些配置。虚拟数据阶段随便打,不会骚扰真机。
|
||||
- **mock=false**:真发。要求该厂商凭据已配置(缺则 400 报缺失键);厂商 API 报错回 502。
|
||||
注意 vivo 未上架前是测试推送模式(VIVO_PUSH_MODE=1),目标手机要先在 vivo 后台加为测试设备。
|
||||
- **createNotification=true**:同时往消息中心 mock 列表插一条同类型未读通知并把
|
||||
notificationId 放进 push extras → 客户端点击 push 后调 POST /notifications/read
|
||||
{ids:[notificationId]} 即可闭环验证 PRD §4 的 push 已读联动。
|
||||
"""
|
||||
# ---- 1. 解析推送目标(vendor + token):直填优先,缺则按 deviceId 反查已注册设备 ----
|
||||
vendor_raw = req.vendor.strip()
|
||||
push_token = req.push_token.strip()
|
||||
if (not vendor_raw or not push_token) and req.device_id.strip():
|
||||
device = device_repo.get_device(db, user_id=user.id, device_id=req.device_id.strip())
|
||||
if device is not None:
|
||||
vendor_raw = vendor_raw or (device.push_vendor or "")
|
||||
push_token = push_token or (device.push_token or "")
|
||||
|
||||
vendor = vendor_push.normalize_vendor(vendor_raw)
|
||||
if not vendor or vendor not in vendor_push.SUPPORTED_VENDORS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"vendor 无效或无法从设备推断,支持: {', '.join(_VENDOR_ORDER)}",
|
||||
)
|
||||
if not push_token:
|
||||
# mock 模式给个占位 token,让「只想看看渲染结果」的调用免造数据;真发必须给真 token。
|
||||
if req.mock:
|
||||
push_token = "mock-token"
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="push token 未知:请直传 pushToken,或先用该设备调 /api/v1/device/register 上报",
|
||||
)
|
||||
|
||||
# ---- 2. 组装文案与 extras:直填 > type 模板 > 通用测试文案 ----
|
||||
extras: dict[str, str] = {}
|
||||
notification_id: int | None = None
|
||||
if req.type:
|
||||
try:
|
||||
title, body = catalog.render_push(req.type, req.vars or None)
|
||||
except catalog.UnknownNotificationType as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
extras["type"] = req.type
|
||||
if req.create_notification:
|
||||
item = mock_repo.insert_sample(user.id, req.type)
|
||||
notification_id = item.id
|
||||
extras.update({str(k): str(v) for k, v in item.extra.items()})
|
||||
extras["notificationId"] = str(item.id)
|
||||
else:
|
||||
label = vendor_push.VENDOR_LABELS[vendor]
|
||||
title = _GENERIC_TEST_TITLE
|
||||
body = _GENERIC_TEST_BODY.format(label=label)
|
||||
extras["type"] = "push_test"
|
||||
if req.title.strip():
|
||||
title = req.title.strip()
|
||||
if req.content.strip():
|
||||
body = req.content.strip()
|
||||
|
||||
# ---- 3. 发送(mock / 真发) ----
|
||||
missing = vendor_push.missing_settings(vendor)
|
||||
vendor_response = None
|
||||
if req.mock:
|
||||
vendor_push.send_notification(
|
||||
vendor, push_token, title=title, body=body, extras=extras, mock=True
|
||||
)
|
||||
else:
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"{vendor_push.VENDOR_LABELS[vendor]}推送凭据未配置,先在 .env 补上: "
|
||||
f"{', '.join(missing)}",
|
||||
)
|
||||
try:
|
||||
vendor_response = vendor_push.send_notification(
|
||||
vendor, push_token, title=title, body=body, extras=extras
|
||||
)
|
||||
except vendor_push.VendorPushError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"厂商推送失败: {e}"
|
||||
) from e
|
||||
|
||||
logger.info(
|
||||
"push test user_id=%d vendor=%s type=%s mock=%s notification_id=%s",
|
||||
user.id, vendor, req.type or "generic", req.mock, notification_id,
|
||||
)
|
||||
return PushTestOut(
|
||||
ok=True,
|
||||
mock=req.mock,
|
||||
vendor=vendor,
|
||||
title=title,
|
||||
body=body,
|
||||
extras=extras,
|
||||
notification_id=notification_id,
|
||||
missing_keys=missing,
|
||||
vendor_response=vendor_response,
|
||||
)
|
||||
@@ -202,6 +202,9 @@ def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> Withdraw
|
||||
order = crud_wallet.create_withdraw(
|
||||
db, user.id, req.amount_cents, source=req.source,
|
||||
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:
|
||||
raise HTTPException(
|
||||
|
||||
+104
-2
@@ -66,9 +66,61 @@ class Settings(BaseSettings):
|
||||
JG_VERIFY_ENDPOINT: str = "https://api.verification.jpush.cn/v1/web/loginTokenVerify"
|
||||
JG_REQUEST_TIMEOUT_SEC: int = 15
|
||||
|
||||
# 无障碍保护存活监控后台任务(pull 后置检测;本期不接推送)
|
||||
# ===== 厂商直推(无障碍保护存活告警)=====
|
||||
ANDROID_PACKAGE_NAME: str = "com.jishisongfu.shaguabijia"
|
||||
PUSH_REQUEST_TIMEOUT_SEC: int = 15
|
||||
PUSH_TIME_TO_LIVE_SEC: int = 86400
|
||||
|
||||
HONOR_PUSH_APP_ID: str = ""
|
||||
HONOR_PUSH_CLIENT_ID: str = ""
|
||||
HONOR_PUSH_CLIENT_SECRET: str = ""
|
||||
HONOR_PUSH_TOKEN_ENDPOINT: str = "https://iam.developer.honor.com/auth/token"
|
||||
HONOR_PUSH_SEND_ENDPOINT_TEMPLATE: str = (
|
||||
"https://push-api.cloud.honor.com/api/v1/{app_id}/sendMessage"
|
||||
)
|
||||
|
||||
# 华为 Push Kit:AGC 控制台 → 项目设置 → 常规 → 应用,取 AppId + AppSecret
|
||||
# (OAuth 换 token 时 client_id 即 AppId)。发送走 v1 messages:send,成功码 80000000。
|
||||
HUAWEI_PUSH_APP_ID: str = ""
|
||||
HUAWEI_PUSH_APP_SECRET: str = ""
|
||||
HUAWEI_PUSH_TOKEN_ENDPOINT: str = "https://oauth-login.cloud.huawei.com/oauth2/v3/token"
|
||||
HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE: str = (
|
||||
"https://push-api.cloud.huawei.com/v1/{app_id}/messages:send"
|
||||
)
|
||||
|
||||
VIVO_PUSH_APP_ID: str = ""
|
||||
VIVO_PUSH_APP_KEY: str = ""
|
||||
VIVO_PUSH_APP_SECRET: str = ""
|
||||
VIVO_PUSH_AUTH_ENDPOINT: str = "https://api-push.vivo.com.cn/message/auth"
|
||||
VIVO_PUSH_SEND_ENDPOINT: str = "https://api-push.vivo.com.cn/message/send"
|
||||
VIVO_PUSH_MODE: int = 1 # 0=正式推送,1=测试推送(未上架 vivo 时用)
|
||||
VIVO_PUSH_NOTIFY_TYPE: int = 4 # 1=无,2=响铃,3=振动,4=响铃+振动
|
||||
VIVO_PUSH_CATEGORY: str = "DEVICE_REMINDER"
|
||||
|
||||
XIAOMI_PUSH_APP_SECRET: str = ""
|
||||
XIAOMI_PUSH_SEND_ENDPOINT: str = "https://api.xmpush.xiaomi.com/v3/message/regid"
|
||||
XIAOMI_PUSH_CHANNEL_ID: str = ""
|
||||
XIAOMI_PUSH_TEMPLATE_ID: str = ""
|
||||
XIAOMI_PUSH_TEMPLATE_TITLE: str = ""
|
||||
XIAOMI_PUSH_TEMPLATE_DESCRIPTION: str = ""
|
||||
XIAOMI_PUSH_TEMPLATE_PARAM_JSON: str = ""
|
||||
|
||||
OPPO_PUSH_APP_KEY: str = ""
|
||||
OPPO_PUSH_MASTER_SECRET: str = ""
|
||||
OPPO_PUSH_AUTH_ENDPOINT: str = "https://api.push.oppomobile.com/server/v1/auth"
|
||||
OPPO_PUSH_SEND_ENDPOINT: str = (
|
||||
"https://api.push.oppomobile.com/server/v1/message/notification/unicast"
|
||||
)
|
||||
# OPPO 新消息分类(2024-11-20 后创建的应用必须携带,否则可能被拒/限):
|
||||
# channel_id=通知栏通道(OPPO 后台「通道ID」),category=消息分类 code(如 MARKETING 内容营销)。
|
||||
# notify_level=提醒方式(0=不传走 OPPO 默认;内容营销类仅支持 1 通知栏/2 通知栏+锁屏)。
|
||||
OPPO_PUSH_CHANNEL_ID: str = ""
|
||||
OPPO_PUSH_CATEGORY: str = ""
|
||||
OPPO_PUSH_NOTIFY_LEVEL: int = 0
|
||||
|
||||
# 无障碍保护存活监控后台任务(推送 + pull 后置兜底)
|
||||
HEARTBEAT_MONITOR_ENABLED: bool = True # 总开关
|
||||
HEARTBEAT_TIMEOUT_MINUTES: int = 10 # 多久没心跳算掉线(≈3 个客户端心跳周期)
|
||||
HEARTBEAT_TIMEOUT_MINUTES: int = 60 # 多久没心跳算掉线(1 小时,避免短暂离线误判被杀)
|
||||
HEARTBEAT_SCAN_INTERVAL_SEC: int = 60 # 扫描周期
|
||||
|
||||
# ===== 短信 =====
|
||||
@@ -113,6 +165,21 @@ class Settings(BaseSettings):
|
||||
"""美团 CPS 凭证齐全(缺则接口返空,而非 502)。"""
|
||||
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),做用户级群统计。
|
||||
# ⚠️ 区别于 WECHAT_APP_ID(那是 App 移动应用,用于微信支付);这是【已认证服务号】。
|
||||
@@ -211,6 +278,41 @@ class Settings(BaseSettings):
|
||||
"""回调开关打开且至少配了一个验签密钥,才接受发奖回调。"""
|
||||
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-backend 默认跑在 8000。/api/v1/coupon/step 会透传到这里的 /api/coupon/step
|
||||
PRICEBOT_BASE_URL: str = "http://localhost:8000"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""无障碍保护存活监控后台任务。
|
||||
|
||||
周期扫描「曾经保护过、当前 alive、心跳超时」的设备 = App 被彻底杀掉/无障碍已停(心跳断了),
|
||||
**命中即在服务器终端打印告警**(本期先不接推送,工程量大,用终端打印代替真实通知);并把状态机
|
||||
**命中即在服务器终端打印告警并尝试厂商直推**;并把状态机
|
||||
推进到 notified 防每轮重复打印(心跳恢复时由 repositories.device.touch_heartbeat 重置回 alive)。
|
||||
结构仿 withdraw_reconcile_worker(单实例锁 + asyncio 轮询 + 优雅退出)。
|
||||
|
||||
@@ -22,6 +22,7 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.session import SessionLocal
|
||||
from app.integrations import vendor_push
|
||||
from app.repositories import device as device_repo
|
||||
|
||||
logger = logging.getLogger("shagua.heartbeat_monitor")
|
||||
@@ -71,32 +72,66 @@ def _silent_seconds(last: datetime | None) -> int | None:
|
||||
"""距上次心跳的秒数(兼容 sqlite 取回的 naive datetime)。"""
|
||||
if last is None:
|
||||
return None
|
||||
ref = datetime.now(timezone.utc) if last.tzinfo is not None else datetime.utcnow()
|
||||
ref = datetime.now(timezone.utc) if last.tzinfo is not None else datetime.utcnow() # noqa: UP017
|
||||
return int((ref - last).total_seconds())
|
||||
|
||||
|
||||
def _scan_once(timeout_minutes: int) -> dict:
|
||||
"""扫描一轮:找出心跳超时(App 被彻底杀掉/无障碍已停)的设备,在**服务器终端打印**告警代替真实推送。
|
||||
"""扫描一轮:找出心跳超时(App 被彻底杀掉/无障碍已停)的设备并召回。
|
||||
|
||||
本期不接推送(极光/厂商通道工程量大),只做服务端掉线检测:命中即 logger.warning 打印到终端,
|
||||
并把状态机推进到 notified 防每轮重复打印(心跳恢复时 touch_heartbeat 会重置回 alive)。
|
||||
有 push_vendor + push_token 时先发厂商直推,无 token 或推送失败时仍置
|
||||
kill_alert_pending,客户端下次进 App 继续走后置提醒兜底。
|
||||
"""
|
||||
notified = 0
|
||||
pushed = 0
|
||||
push_failed = 0
|
||||
with SessionLocal() as db:
|
||||
overdue = device_repo.list_overdue(db, timeout_minutes=timeout_minutes)
|
||||
for device in overdue:
|
||||
silent = _silent_seconds(device.last_heartbeat_at)
|
||||
logger.warning(
|
||||
"[掉线检测] user_id=%s device_id=%s 已 %s 秒无心跳(阈值 %d 分钟)"
|
||||
" → 判定 App 已被杀/无障碍已停。【已置 kill_alert_pending: 用户下次进 App 将弹「开启自启动」引导(后置检测);推送本期未接】",
|
||||
" → 判定 App 已被杀/无障碍已停。",
|
||||
device.user_id,
|
||||
device.device_id,
|
||||
silent if silent is not None else "?",
|
||||
timeout_minutes,
|
||||
)
|
||||
if device.push_vendor and device.push_token:
|
||||
try:
|
||||
vendor_push.send_accessibility_disabled(
|
||||
device.push_vendor,
|
||||
device.push_token,
|
||||
)
|
||||
pushed += 1
|
||||
logger.info(
|
||||
"[掉线检测] push sent user_id=%s device_id=%s vendor=%s",
|
||||
device.user_id,
|
||||
device.device_id,
|
||||
device.push_vendor,
|
||||
)
|
||||
except vendor_push.VendorPushError as e:
|
||||
push_failed += 1
|
||||
logger.warning(
|
||||
"[掉线检测] push failed user_id=%s device_id=%s error=%s",
|
||||
device.user_id,
|
||||
device.device_id,
|
||||
e,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"[掉线检测] device has no push vendor/token, skip push user_id=%s device_id=%s",
|
||||
device.user_id,
|
||||
device.device_id,
|
||||
)
|
||||
device_repo.mark_notified(db, device_id_pk=device.id)
|
||||
notified += 1
|
||||
return {"checked": len(overdue), "notified": notified}
|
||||
return {
|
||||
"checked": len(overdue),
|
||||
"notified": notified,
|
||||
"pushed": pushed,
|
||||
"push_failed": push_failed,
|
||||
}
|
||||
|
||||
|
||||
async def _run_loop() -> None:
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
"""消息通知中心:13 种通知类型的静态目录 + Push 文案模板。
|
||||
|
||||
对应 PRD《消息通知中心》:§1 类型清单 / §3 字段元素 / §5 Push 文案。
|
||||
这里只放**静态定义**(分类、版式、标题、操作行、Push 模板),供两处消费:
|
||||
- repositories/notification_mock.py 生成消息中心 mock 列表(虚拟数据阶段)
|
||||
- api/v1/push.py 渲染 13 类 push 标题/文案(厂商推送 + 测试端点)
|
||||
|
||||
PRD 文案规范(§5):push 标题 ≤11 字、固定文案不带变量;变量只出现在正文里且尽量前置。
|
||||
模板变量用 `{name}` 占位,渲染时缺省回退 sample_vars(PRD 示例值),保证 mock 阶段随时可发。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 分类(仅作卡片头部的分类标签展示;列表不按分类分组——PRD §1 的分组已确认取消,全表时间倒序)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CATEGORY_WITHDRAW = "withdraw_assistant"
|
||||
CATEGORY_SYSTEM = "system"
|
||||
CATEGORY_FEEDBACK = "feedback"
|
||||
CATEGORY_REPORT = "report"
|
||||
CATEGORY_INVITE = "invite"
|
||||
|
||||
# key → 中文标签
|
||||
CATEGORIES: dict[str, str] = {
|
||||
CATEGORY_WITHDRAW: "提现助手",
|
||||
CATEGORY_SYSTEM: "系统通知",
|
||||
CATEGORY_FEEDBACK: "我的反馈",
|
||||
CATEGORY_REPORT: "我的爆料",
|
||||
CATEGORY_INVITE: "好友邀请",
|
||||
}
|
||||
|
||||
|
||||
def category_label(key: str) -> str:
|
||||
return CATEGORIES[key]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 卡片版式(PRD §3「版式」列;前端按此渲染五种卡)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CARD_DUAL_AMOUNT = "dual_amount" # 双金额卡(金币数 + 现金数)
|
||||
CARD_WITHDRAW = "withdraw" # 提现卡(¥金额)
|
||||
CARD_PLAIN_TEXT = "plain_text" # 纯文本卡(无数值)
|
||||
CARD_COIN_REWARD = "coin_reward" # 金币奖励卡(金币数 + 单位「金币」)
|
||||
CARD_FRIEND_CASH = "friend_cash" # 好友现金卡(¥金额)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NotificationType:
|
||||
"""一种通知类型的静态定义(卡片元数据 + Push 模板)。"""
|
||||
|
||||
key: str # 类型 key(接口 type 字段;前端按它决定点击跳转,见 PRD §2)
|
||||
category: str # 分类 key(CATEGORIES 之一)
|
||||
card_style: str # 卡片版式(CARD_* 之一)
|
||||
card_title: str # 卡片标题(PRD §3「标题」列)
|
||||
action_text: str | None # 操作行文案;None = 无操作行(如「提现成功」)
|
||||
push_title: str # push 标题(≤11 字固定文案,PRD §5)
|
||||
push_body_template: str # push 正文模板,`{var}` 为变量
|
||||
sample_vars: dict[str, str] = field(default_factory=dict) # PRD 示例值,渲染缺省回退
|
||||
|
||||
|
||||
# 13 种类型,编号/文案与 PRD §1/§3/§5 一一对应(插入顺序 = PRD 编号顺序)。
|
||||
TYPES: dict[str, NotificationType] = {
|
||||
t.key: t
|
||||
for t in [
|
||||
# -- 提现助手 -------------------------------------------------------
|
||||
NotificationType(
|
||||
key="reward_expiring",
|
||||
category=CATEGORY_WITHDRAW,
|
||||
card_style=CARD_DUAL_AMOUNT,
|
||||
card_title="金币现金奖励即将失效",
|
||||
action_text="立即激活您的收益",
|
||||
push_title="您的奖励即将失效",
|
||||
push_body_template="{coins}金币和{cash}元现金{days}天后失效,完成快来激活收益",
|
||||
sample_vars={"coins": "86", "cash": "12.80", "days": "3"},
|
||||
),
|
||||
NotificationType(
|
||||
key="reward_expired",
|
||||
category=CATEGORY_WITHDRAW,
|
||||
card_style=CARD_DUAL_AMOUNT,
|
||||
card_title="金币现金奖励已失效",
|
||||
action_text="立即赚取新收益",
|
||||
push_title="您的奖励已失效",
|
||||
push_body_template="{coins}金币和{cash}元现金已过期,完成一次一键领券或一键比价可赚取新收益",
|
||||
sample_vars={"coins": "35", "cash": "0.60"},
|
||||
),
|
||||
NotificationType(
|
||||
key="withdraw_success",
|
||||
category=CATEGORY_WITHDRAW,
|
||||
card_style=CARD_WITHDRAW,
|
||||
card_title="提现成功",
|
||||
action_text=None, # PRD §3:提现成功卡无操作行,点击也无跳转、仅消红点
|
||||
push_title="提现到账提醒",
|
||||
push_body_template="¥{amount}已存入您的微信钱包,点击查看到账详情",
|
||||
sample_vars={"amount": "0.50"},
|
||||
),
|
||||
NotificationType(
|
||||
key="withdraw_failed",
|
||||
category=CATEGORY_WITHDRAW,
|
||||
card_style=CARD_WITHDRAW,
|
||||
card_title="提现失败,款项已退回",
|
||||
action_text="重新提现",
|
||||
push_title="提现失败,款项已退回",
|
||||
push_body_template="¥{amount}因{reason}退回现金余额,点击重新提现",
|
||||
sample_vars={"amount": "3.50", "reason": "微信零钱未实名"},
|
||||
),
|
||||
# -- 系统通知(权限异常 ×4;标题里的功能名按类型写死,见 PRD §1/§3)----
|
||||
NotificationType(
|
||||
key="perm_accessibility",
|
||||
category=CATEGORY_SYSTEM,
|
||||
card_style=CARD_PLAIN_TEXT,
|
||||
card_title="检测到您的比价功能已失效",
|
||||
action_text="去开启",
|
||||
push_title="检测到您的比价功能已失效",
|
||||
push_body_template="未开启将导致核心功能不可用,请尽快来傻瓜比价开启",
|
||||
),
|
||||
NotificationType(
|
||||
key="perm_battery",
|
||||
category=CATEGORY_SYSTEM,
|
||||
card_style=CARD_PLAIN_TEXT,
|
||||
card_title="检测到您的比价续航保护已失效",
|
||||
action_text="去开启",
|
||||
push_title="检测到您的比价续航保护已失效",
|
||||
push_body_template="未开启将导致核心功能不可用,请尽快来傻瓜比价开启",
|
||||
),
|
||||
NotificationType(
|
||||
key="perm_autostart",
|
||||
category=CATEGORY_SYSTEM,
|
||||
card_style=CARD_PLAIN_TEXT,
|
||||
card_title="检测到您的比价启动保护已失效",
|
||||
action_text="去开启",
|
||||
push_title="检测到您的比价启动保护已失效",
|
||||
push_body_template="未开启将导致核心功能不可用,请尽快来傻瓜比价开启",
|
||||
),
|
||||
NotificationType(
|
||||
key="perm_overlay",
|
||||
category=CATEGORY_SYSTEM,
|
||||
card_style=CARD_PLAIN_TEXT,
|
||||
card_title="检测到您的比价按钮已失效",
|
||||
action_text="去开启",
|
||||
push_title="检测到您的比价按钮已失效",
|
||||
push_body_template="未开启将导致核心功能不可用,请尽快来傻瓜比价开启",
|
||||
),
|
||||
# -- 我的反馈 -------------------------------------------------------
|
||||
NotificationType(
|
||||
key="feedback_reply",
|
||||
category=CATEGORY_FEEDBACK,
|
||||
card_style=CARD_PLAIN_TEXT,
|
||||
card_title="傻瓜比价官方回复了您的反馈",
|
||||
action_text="查看详情",
|
||||
push_title="您的反馈有回复啦",
|
||||
push_body_template="您提的建议我们认真看过了,来看看我们的回复吧~",
|
||||
),
|
||||
NotificationType(
|
||||
key="feedback_reward",
|
||||
category=CATEGORY_FEEDBACK,
|
||||
card_style=CARD_COIN_REWARD,
|
||||
card_title="反馈奖励",
|
||||
action_text="查看反馈详情",
|
||||
push_title="反馈奖励已到账",
|
||||
push_body_template="谢谢您帮傻瓜比价变得更好,{coins}金币已到账,还有一条给您的留言~",
|
||||
sample_vars={"coins": "300"},
|
||||
),
|
||||
# -- 我的爆料 -------------------------------------------------------
|
||||
NotificationType(
|
||||
key="report_approved",
|
||||
category=CATEGORY_REPORT,
|
||||
card_style=CARD_COIN_REWARD,
|
||||
card_title="爆料审核通过",
|
||||
action_text="查看爆料详情",
|
||||
push_title="爆料审核通过",
|
||||
push_body_template="您爆料的「{store}」更低价审核通过,{coins}金币已到账,感谢您的分享",
|
||||
sample_vars={"store": "蜀大侠火锅", "coins": "1000"},
|
||||
),
|
||||
# -- 好友邀请 -------------------------------------------------------
|
||||
NotificationType(
|
||||
key="invite_order_reward",
|
||||
category=CATEGORY_INVITE,
|
||||
card_style=CARD_FRIEND_CASH,
|
||||
card_title="好友比价成功,现金已到账",
|
||||
action_text="邀请更多好友赚现金",
|
||||
push_title="您的邀请奖励已到账",
|
||||
push_body_template="您的好友「{nickname}」完成首次下单,{amount}元现金已到账",
|
||||
sample_vars={"nickname": "柚子", "amount": "2"},
|
||||
),
|
||||
NotificationType(
|
||||
key="invite_remind",
|
||||
category=CATEGORY_INVITE,
|
||||
card_style=CARD_PLAIN_TEXT,
|
||||
card_title="你邀请的好友还差一步",
|
||||
action_text="去提醒 TA",
|
||||
push_title="提醒好友完成比价的奖励",
|
||||
push_body_template="您的好友「{nickname}」还没完成比价下单,提醒TA完成,您可得{amount}元现金",
|
||||
sample_vars={"nickname": "阿泽", "amount": "2"},
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
class UnknownNotificationType(ValueError):
|
||||
"""type key 不在 13 种类型之内。"""
|
||||
|
||||
|
||||
def get_type(type_key: str) -> NotificationType:
|
||||
ntype = TYPES.get(type_key)
|
||||
if ntype is None:
|
||||
raise UnknownNotificationType(
|
||||
f"unknown notification type: {type_key!r} (可选: {', '.join(TYPES)})"
|
||||
)
|
||||
return ntype
|
||||
|
||||
|
||||
def render_push(type_key: str, variables: dict[str, str] | None = None) -> tuple[str, str]:
|
||||
"""渲染某类型的 push (标题, 正文)。
|
||||
|
||||
variables 覆盖模板变量;缺的变量回退 sample_vars(PRD 示例值)——保证虚拟数据
|
||||
阶段不传变量也能发出完整文案。多余的变量忽略。
|
||||
"""
|
||||
ntype = get_type(type_key)
|
||||
merged = {**ntype.sample_vars, **(variables or {})}
|
||||
|
||||
class _Fallback(dict):
|
||||
def __missing__(self, key: str) -> str: # 模板变量既没传也没示例值 → 保留 {key} 原样
|
||||
return "{" + key + "}"
|
||||
|
||||
body = ntype.push_body_template.format_map(_Fallback(merged))
|
||||
return ntype.push_title, body
|
||||
|
||||
|
||||
def push_variable_names(type_key: str) -> list[str]:
|
||||
"""列出模板里出现的变量名(给 /push/templates 预览用)。"""
|
||||
import string
|
||||
|
||||
ntype = get_type(type_key)
|
||||
return [
|
||||
fname
|
||||
for _, fname, _, _ in string.Formatter().parse(ntype.push_body_template)
|
||||
if fname
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
"""透传到 pricebot 的共享 httpx.AsyncClient 单例。
|
||||
|
||||
为什么不能每请求新建(coupon.py / compare.py 老写法 async with httpx.AsyncClient(...)):
|
||||
① 每次构造都重建一套 SSL 上下文(httpx.create_ssl_context 加载 certifi CA),实测
|
||||
~1s+/次;而 pricebot 是纯 http 透传,根本用不到 TLS → 纯浪费,且每帧重交一次。
|
||||
② trust_env 默认 True 会读进程 HTTP_PROXY,把 http://localhost:8000 这条本地透传整个
|
||||
塞进本机代理(如 Clash 7897),恒定再多几秒。
|
||||
单例:启动只建一次(SSL/连接池一次性),keep-alive 复用 TCP,每帧降到个位数 ms。
|
||||
trust_env=False:对齐 integrations/meituan.py 的既有约定,不被进程代理误导,直连 pricebot。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
_client: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
def get_pricebot_client() -> httpx.AsyncClient:
|
||||
"""取透传单例。lifespan 启动会预热;未预热(如测试态)懒建兜底。
|
||||
|
||||
超时不在此固化(coupon 30s / compare 60s 不同),由调用点 client.post(timeout=...) 传。
|
||||
懒建无 await,asyncio 单线程下不会有并发竞态。
|
||||
"""
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = httpx.AsyncClient(trust_env=False)
|
||||
return _client
|
||||
|
||||
|
||||
async def aclose_pricebot_client() -> None:
|
||||
"""lifespan 关停时调,优雅关连接池。"""
|
||||
global _client
|
||||
if _client is not None:
|
||||
await _client.aclose()
|
||||
_client = None
|
||||
@@ -0,0 +1,163 @@
|
||||
"""京东联盟 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}
|
||||
@@ -0,0 +1,148 @@
|
||||
"""穿山甲 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
|
||||
@@ -0,0 +1,519 @@
|
||||
"""厂商直推集成(荣耀 / 华为 / 小米 / OPPO / vivo)。
|
||||
|
||||
服务端不经由 JPush Push API,而是按客户端上报的 push_vendor + push_token
|
||||
分发到各手机厂商的服务端 API。
|
||||
|
||||
对外两个入口:
|
||||
- send_notification() 通用:任意标题/正文/extras(消息中心 13 类推送走这里),
|
||||
mock=True 时不真调厂商、返回渲染结果(虚拟数据联调用)
|
||||
- send_accessibility_disabled() 旧:无障碍掉线召回(heartbeat_monitor_worker 在用),
|
||||
已改为 send_notification 的薄封装,行为不变
|
||||
|
||||
各厂商鉴权方式:荣耀/华为 OAuth client_credentials 换 access_token(进程内缓存);
|
||||
vivo/OPPO 签名换 authToken(缓存 24h);小米直接 AppSecret 走 Authorization 头。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger("shagua.vendor_push")
|
||||
|
||||
TYPE_ACCESSIBILITY_DISABLED = "accessibility_disabled"
|
||||
SUPPORTED_VENDORS = frozenset({"honor", "huawei", "vivo", "xiaomi", "oppo"})
|
||||
|
||||
# vendor key → 中文名(测试/配置状态接口展示用)
|
||||
VENDOR_LABELS: dict[str, str] = {
|
||||
"honor": "荣耀",
|
||||
"huawei": "华为",
|
||||
"xiaomi": "小米",
|
||||
"oppo": "OPPO",
|
||||
"vivo": "vivo",
|
||||
}
|
||||
|
||||
# 各厂商真发推送所需的 settings 键(缺任一即视为未配置;/api/v1/push/vendors 据此报缺)
|
||||
REQUIRED_SETTINGS: dict[str, tuple[str, ...]] = {
|
||||
"honor": ("HONOR_PUSH_APP_ID", "HONOR_PUSH_CLIENT_ID", "HONOR_PUSH_CLIENT_SECRET"),
|
||||
"huawei": ("HUAWEI_PUSH_APP_ID", "HUAWEI_PUSH_APP_SECRET"),
|
||||
"xiaomi": ("XIAOMI_PUSH_APP_SECRET",),
|
||||
"oppo": ("OPPO_PUSH_APP_KEY", "OPPO_PUSH_MASTER_SECRET"),
|
||||
"vivo": ("VIVO_PUSH_APP_ID", "VIVO_PUSH_APP_KEY", "VIVO_PUSH_APP_SECRET"),
|
||||
}
|
||||
|
||||
|
||||
def missing_settings(vendor: str) -> list[str]:
|
||||
"""该厂商还缺哪些配置键(全配齐返回空列表)。vendor 需已 normalize。"""
|
||||
return [key for key in REQUIRED_SETTINGS.get(vendor, ()) if not getattr(settings, key, "")]
|
||||
|
||||
|
||||
class VendorPushError(Exception):
|
||||
"""厂商推送调用失败。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CachedToken:
|
||||
value: str
|
||||
expires_at: float
|
||||
|
||||
|
||||
_token_cache: dict[str, _CachedToken] = {}
|
||||
|
||||
|
||||
def normalize_vendor(push_vendor: str | None) -> str | None:
|
||||
if not push_vendor:
|
||||
return None
|
||||
vendor = push_vendor.strip().lower()
|
||||
aliases = {
|
||||
"hihonor": "honor",
|
||||
"荣耀": "honor",
|
||||
"hms": "huawei",
|
||||
"华为": "huawei",
|
||||
"harmony": "huawei",
|
||||
"harmonyos": "huawei",
|
||||
"mi": "xiaomi",
|
||||
"小米": "xiaomi",
|
||||
"oneplus": "oppo",
|
||||
"realme": "oppo",
|
||||
}
|
||||
return aliases.get(vendor, vendor)
|
||||
|
||||
|
||||
def send_notification(
|
||||
push_vendor: str,
|
||||
push_token: str,
|
||||
*,
|
||||
title: str,
|
||||
body: str,
|
||||
extras: dict[str, str] | None = None,
|
||||
mock: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""按厂商 token 向单台设备发送一条通知(通用入口)。
|
||||
|
||||
- extras:透传给客户端的自定义键值(值统一 string,兼容各厂商限制)。消息中心推送约定
|
||||
至少带 {"type": <13 种类型 key>, "notificationId": <站内消息 id>},客户端据此
|
||||
深链落地 + 调 /notifications/read 同步置读(PRD §4 push 联动)。
|
||||
- mock=True:不真调厂商 API,校验参数后原样返回渲染结果(虚拟数据阶段联调/自动化测试用)。
|
||||
"""
|
||||
vendor = normalize_vendor(push_vendor)
|
||||
token = push_token.strip() if push_token else ""
|
||||
if not vendor or vendor not in SUPPORTED_VENDORS:
|
||||
raise VendorPushError(f"unsupported push vendor: {push_vendor}")
|
||||
if not token:
|
||||
raise VendorPushError("push token is empty")
|
||||
extras = {str(k): str(v) for k, v in (extras or {}).items()}
|
||||
|
||||
if mock:
|
||||
logger.info(
|
||||
"[mock push] vendor=%s token=%s... title=%s body=%s extras=%s",
|
||||
vendor, token[:12], title, body, extras,
|
||||
)
|
||||
return {
|
||||
"mock": True,
|
||||
"vendor": vendor,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"extras": extras,
|
||||
}
|
||||
|
||||
dispatch: dict[str, Callable[[str, str, str, dict[str, str]], dict[str, Any]]] = {
|
||||
"honor": _send_honor,
|
||||
"huawei": _send_huawei,
|
||||
"vivo": _send_vivo,
|
||||
"xiaomi": _send_xiaomi,
|
||||
"oppo": _send_oppo,
|
||||
}
|
||||
return dispatch[vendor](token, title, body, extras)
|
||||
|
||||
|
||||
def send_accessibility_disabled(
|
||||
push_vendor: str,
|
||||
push_token: str,
|
||||
*,
|
||||
title: str = "保护已关闭",
|
||||
alert: str = "傻瓜比价的无障碍保护被关了,点此重新开启,继续帮你自动比价省钱。",
|
||||
) -> dict[str, Any]:
|
||||
"""按厂商 token 向单台设备发送无障碍掉线通知(heartbeat_monitor_worker 在用,行为不变)。"""
|
||||
return send_notification(
|
||||
push_vendor,
|
||||
push_token,
|
||||
title=title,
|
||||
body=alert,
|
||||
extras={"type": TYPE_ACCESSIBILITY_DISABLED},
|
||||
)
|
||||
|
||||
|
||||
def _require(value: str, name: str) -> str:
|
||||
if not value:
|
||||
raise VendorPushError(f"{name} not configured")
|
||||
return value
|
||||
|
||||
|
||||
def _request_json(
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
expected_status: tuple[int, ...] = (200,),
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
resp = httpx.request(
|
||||
method,
|
||||
url,
|
||||
timeout=settings.PUSH_REQUEST_TIMEOUT_SEC,
|
||||
**kwargs,
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
raise VendorPushError(f"push http error: {e}") from e
|
||||
|
||||
if resp.status_code not in expected_status:
|
||||
logger.error("vendor push http failed url=%s http=%s body=%s", url, resp.status_code, resp.text[:500])
|
||||
raise VendorPushError(f"push http {resp.status_code}")
|
||||
try:
|
||||
return resp.json()
|
||||
except ValueError as e:
|
||||
raise VendorPushError(f"push invalid json: {resp.text[:200]}") from e
|
||||
|
||||
|
||||
def _request_form(
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
expected_status: tuple[int, ...] = (200,),
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
resp = httpx.request(
|
||||
method,
|
||||
url,
|
||||
timeout=settings.PUSH_REQUEST_TIMEOUT_SEC,
|
||||
**kwargs,
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
raise VendorPushError(f"push http error: {e}") from e
|
||||
|
||||
if resp.status_code not in expected_status:
|
||||
logger.error("vendor push http failed url=%s http=%s body=%s", url, resp.status_code, resp.text[:500])
|
||||
raise VendorPushError(f"push http {resp.status_code}")
|
||||
try:
|
||||
return resp.json()
|
||||
except ValueError as e:
|
||||
raise VendorPushError(f"push invalid json: {resp.text[:200]}") from e
|
||||
|
||||
|
||||
def _cache_get(key: str) -> str | None:
|
||||
cached = _token_cache.get(key)
|
||||
if cached and cached.expires_at > time.time() + 60:
|
||||
return cached.value
|
||||
return None
|
||||
|
||||
|
||||
def _cache_put(key: str, value: str, expires_in: int | float | None) -> str:
|
||||
ttl = int(expires_in or 3600)
|
||||
_token_cache[key] = _CachedToken(value=value, expires_at=time.time() + max(60, ttl - 60))
|
||||
return value
|
||||
|
||||
|
||||
def _honor_access_token() -> str:
|
||||
cache_key = "honor"
|
||||
cached = _cache_get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
client_id = _require(settings.HONOR_PUSH_CLIENT_ID, "HONOR_PUSH_CLIENT_ID")
|
||||
client_secret = _require(settings.HONOR_PUSH_CLIENT_SECRET, "HONOR_PUSH_CLIENT_SECRET")
|
||||
data = _request_form(
|
||||
"POST",
|
||||
settings.HONOR_PUSH_TOKEN_ENDPOINT,
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
token = data.get("access_token")
|
||||
if not token:
|
||||
raise VendorPushError(f"honor auth failed: {data}")
|
||||
return _cache_put(cache_key, str(token), data.get("expires_in"))
|
||||
|
||||
|
||||
def _send_honor(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]:
|
||||
app_id = _require(settings.HONOR_PUSH_APP_ID, "HONOR_PUSH_APP_ID")
|
||||
access_token = _honor_access_token()
|
||||
payload = {
|
||||
"data": json.dumps(extras, ensure_ascii=False),
|
||||
"notification": {"title": title, "body": body},
|
||||
"android": {
|
||||
"ttl": f"{settings.PUSH_TIME_TO_LIVE_SEC}s",
|
||||
"targetUserType": 1,
|
||||
"notification": {
|
||||
"title": title,
|
||||
"body": body,
|
||||
"clickAction": {"type": 3},
|
||||
"importance": "NORMAL",
|
||||
},
|
||||
},
|
||||
"token": [token],
|
||||
}
|
||||
data = _request_json(
|
||||
"POST",
|
||||
settings.HONOR_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id),
|
||||
json=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json; charset=UTF-8",
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"timestamp": str(int(time.time() * 1000)),
|
||||
},
|
||||
)
|
||||
code = data.get("code")
|
||||
if code is not None and int(code) != 200:
|
||||
raise VendorPushError(f"honor push failed: {data}")
|
||||
return data
|
||||
|
||||
|
||||
def _huawei_access_token() -> str:
|
||||
"""华为 OAuth2 client_credentials 换 access_token(client_id 即 AGC 应用的 AppId)。"""
|
||||
cache_key = "huawei"
|
||||
cached = _cache_get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
app_id = _require(settings.HUAWEI_PUSH_APP_ID, "HUAWEI_PUSH_APP_ID")
|
||||
app_secret = _require(settings.HUAWEI_PUSH_APP_SECRET, "HUAWEI_PUSH_APP_SECRET")
|
||||
data = _request_form(
|
||||
"POST",
|
||||
settings.HUAWEI_PUSH_TOKEN_ENDPOINT,
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": app_id,
|
||||
"client_secret": app_secret,
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
token = data.get("access_token")
|
||||
if not token:
|
||||
raise VendorPushError(f"huawei auth failed: {data}")
|
||||
return _cache_put(cache_key, str(token), data.get("expires_in"))
|
||||
|
||||
|
||||
def _send_huawei(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]:
|
||||
"""华为 Push Kit 下行消息(v1 messages:send)。成功码 '80000000';
|
||||
'80100000' 为部分成功(单 token 场景仍视为失败,错误里带原始响应便于排障)。"""
|
||||
app_id = _require(settings.HUAWEI_PUSH_APP_ID, "HUAWEI_PUSH_APP_ID")
|
||||
access_token = _huawei_access_token()
|
||||
payload = {
|
||||
"validate_only": False,
|
||||
"message": {
|
||||
"data": json.dumps(extras, ensure_ascii=False),
|
||||
"android": {
|
||||
"ttl": f"{settings.PUSH_TIME_TO_LIVE_SEC}s",
|
||||
"notification": {
|
||||
"title": title,
|
||||
"body": body,
|
||||
"click_action": {"type": 3},
|
||||
"importance": "NORMAL",
|
||||
},
|
||||
},
|
||||
"token": [token],
|
||||
},
|
||||
}
|
||||
data = _request_json(
|
||||
"POST",
|
||||
settings.HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id),
|
||||
json=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json; charset=UTF-8",
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
},
|
||||
)
|
||||
if str(data.get("code", "")) != "80000000":
|
||||
raise VendorPushError(f"huawei push failed: {data}")
|
||||
return data
|
||||
|
||||
|
||||
def _vivo_auth_token() -> str:
|
||||
cache_key = "vivo"
|
||||
cached = _cache_get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
app_id = _require(settings.VIVO_PUSH_APP_ID, "VIVO_PUSH_APP_ID")
|
||||
app_key = _require(settings.VIVO_PUSH_APP_KEY, "VIVO_PUSH_APP_KEY")
|
||||
app_secret = _require(settings.VIVO_PUSH_APP_SECRET, "VIVO_PUSH_APP_SECRET")
|
||||
timestamp = str(int(time.time() * 1000))
|
||||
sign = hashlib.md5(f"{app_id}{app_key}{timestamp}{app_secret}".encode()).hexdigest() # noqa: S324
|
||||
data = _request_json(
|
||||
"POST",
|
||||
settings.VIVO_PUSH_AUTH_ENDPOINT,
|
||||
json={
|
||||
"appId": app_id,
|
||||
"appKey": app_key,
|
||||
"timestamp": timestamp,
|
||||
"sign": sign,
|
||||
},
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
if int(data.get("result", -1)) != 0:
|
||||
raise VendorPushError(f"vivo auth failed: {data}")
|
||||
token = data.get("authToken")
|
||||
if not token:
|
||||
raise VendorPushError(f"vivo auth missing authToken: {data}")
|
||||
return _cache_put(cache_key, str(token), 24 * 3600)
|
||||
|
||||
|
||||
def _send_vivo(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]:
|
||||
app_id = _require(settings.VIVO_PUSH_APP_ID, "VIVO_PUSH_APP_ID")
|
||||
auth_token = _vivo_auth_token()
|
||||
payload: dict[str, Any] = {
|
||||
"appId": app_id,
|
||||
"regId": token,
|
||||
"notifyType": settings.VIVO_PUSH_NOTIFY_TYPE,
|
||||
"title": title,
|
||||
"content": body,
|
||||
"timeToLive": settings.PUSH_TIME_TO_LIVE_SEC,
|
||||
"skipType": 1,
|
||||
"requestId": uuid.uuid4().hex,
|
||||
"pushMode": settings.VIVO_PUSH_MODE,
|
||||
"clientCustomMap": extras,
|
||||
}
|
||||
if settings.VIVO_PUSH_CATEGORY:
|
||||
payload["category"] = settings.VIVO_PUSH_CATEGORY
|
||||
data = _request_json(
|
||||
"POST",
|
||||
settings.VIVO_PUSH_SEND_ENDPOINT,
|
||||
json=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"authToken": auth_token,
|
||||
},
|
||||
)
|
||||
if int(data.get("result", -1)) != 0:
|
||||
raise VendorPushError(f"vivo push failed: {data}")
|
||||
return data
|
||||
|
||||
|
||||
def _send_xiaomi(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]:
|
||||
app_secret = _require(settings.XIAOMI_PUSH_APP_SECRET, "XIAOMI_PUSH_APP_SECRET")
|
||||
message_title = settings.XIAOMI_PUSH_TEMPLATE_TITLE.strip() or title
|
||||
message_description = settings.XIAOMI_PUSH_TEMPLATE_DESCRIPTION.strip() or body
|
||||
form = {
|
||||
"registration_id": token,
|
||||
"restricted_package_name": settings.ANDROID_PACKAGE_NAME,
|
||||
"title": message_title,
|
||||
"description": message_description,
|
||||
"payload": json.dumps(extras, ensure_ascii=False),
|
||||
"pass_through": "0",
|
||||
"notify_type": "-1",
|
||||
"time_to_live": str(settings.PUSH_TIME_TO_LIVE_SEC * 1000),
|
||||
"extra.notify_effect": "1",
|
||||
}
|
||||
if settings.XIAOMI_PUSH_CHANNEL_ID:
|
||||
form["extra.channel_id"] = settings.XIAOMI_PUSH_CHANNEL_ID.strip()
|
||||
if settings.XIAOMI_PUSH_TEMPLATE_ID:
|
||||
form["extra.template_id"] = settings.XIAOMI_PUSH_TEMPLATE_ID.strip()
|
||||
if settings.XIAOMI_PUSH_TEMPLATE_PARAM_JSON:
|
||||
form["extra.template_param"] = _xiaomi_template_param(title, body)
|
||||
data = _request_form(
|
||||
"POST",
|
||||
settings.XIAOMI_PUSH_SEND_ENDPOINT,
|
||||
data=form,
|
||||
headers={"Authorization": f"key={app_secret}"},
|
||||
)
|
||||
code = data.get("code")
|
||||
if code not in (0, "0", None):
|
||||
raise VendorPushError(f"xiaomi push failed: {data}")
|
||||
if str(data.get("result", "ok")).lower() not in ("ok", "success"):
|
||||
raise VendorPushError(f"xiaomi push failed: {data}")
|
||||
return data
|
||||
|
||||
|
||||
def _xiaomi_template_param(title: str, alert: str) -> str:
|
||||
rendered = (
|
||||
settings.XIAOMI_PUSH_TEMPLATE_PARAM_JSON
|
||||
.replace("{title}", title)
|
||||
.replace("{alert}", alert)
|
||||
)
|
||||
try:
|
||||
payload = json.loads(rendered)
|
||||
except ValueError as e:
|
||||
raise VendorPushError("XIAOMI_PUSH_TEMPLATE_PARAM_JSON invalid json") from e
|
||||
if not isinstance(payload, dict):
|
||||
raise VendorPushError("XIAOMI_PUSH_TEMPLATE_PARAM_JSON must be a json object")
|
||||
for key, value in payload.items():
|
||||
if not isinstance(key, str) or not isinstance(value, str):
|
||||
raise VendorPushError("xiaomi template params must be string key-value pairs")
|
||||
if not value.strip() or len(value) > 128:
|
||||
raise VendorPushError("xiaomi template param value length must be 1-128")
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def _oppo_auth_token() -> str:
|
||||
cache_key = "oppo"
|
||||
cached = _cache_get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
app_key = _require(settings.OPPO_PUSH_APP_KEY, "OPPO_PUSH_APP_KEY")
|
||||
master_secret = _require(settings.OPPO_PUSH_MASTER_SECRET, "OPPO_PUSH_MASTER_SECRET")
|
||||
timestamp = str(int(time.time() * 1000))
|
||||
sign = hashlib.sha256(f"{app_key}{timestamp}{master_secret}".encode()).hexdigest()
|
||||
data = _request_form(
|
||||
"POST",
|
||||
settings.OPPO_PUSH_AUTH_ENDPOINT,
|
||||
data={
|
||||
"app_key": app_key,
|
||||
"timestamp": timestamp,
|
||||
"sign": sign,
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
if int(data.get("code", -1)) != 0:
|
||||
raise VendorPushError(f"oppo auth failed: {data}")
|
||||
token = (data.get("data") or {}).get("auth_token") or data.get("auth_token")
|
||||
if not token:
|
||||
raise VendorPushError(f"oppo auth missing auth_token: {data}")
|
||||
return _cache_put(cache_key, str(token), 24 * 3600)
|
||||
|
||||
|
||||
def _send_oppo(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]:
|
||||
auth_token = _oppo_auth_token()
|
||||
ttl_hours = max(1, min(72, settings.PUSH_TIME_TO_LIVE_SEC // 3600))
|
||||
notification: dict[str, Any] = {
|
||||
"app_message_id": f"{extras.get('type', 'notify')}_{uuid.uuid4().hex}",
|
||||
"title": title,
|
||||
"content": body,
|
||||
"click_action_type": 0,
|
||||
"off_line": True,
|
||||
"off_line_ttl": ttl_hours,
|
||||
"action_parameters": json.dumps(extras, ensure_ascii=False),
|
||||
}
|
||||
# 新消息分类(2024-11-20 后创建的 OPPO 应用必须带 category,否则可能被拒收/降级)
|
||||
if settings.OPPO_PUSH_CHANNEL_ID.strip():
|
||||
notification["channel_id"] = settings.OPPO_PUSH_CHANNEL_ID.strip()
|
||||
if settings.OPPO_PUSH_CATEGORY.strip():
|
||||
notification["category"] = settings.OPPO_PUSH_CATEGORY.strip()
|
||||
if settings.OPPO_PUSH_NOTIFY_LEVEL:
|
||||
notification["notify_level"] = settings.OPPO_PUSH_NOTIFY_LEVEL
|
||||
message = {
|
||||
"target_type": 2,
|
||||
"target_value": token,
|
||||
"notification": notification,
|
||||
}
|
||||
data = _request_form(
|
||||
"POST",
|
||||
settings.OPPO_PUSH_SEND_ENDPOINT,
|
||||
data={
|
||||
"auth_token": auth_token,
|
||||
"message": json.dumps(message, ensure_ascii=False),
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
if int(data.get("code", -1)) != 0:
|
||||
raise VendorPushError(f"oppo push failed: {data}")
|
||||
return data
|
||||
+44
-10
@@ -2,18 +2,23 @@
|
||||
|
||||
通过 `uvicorn app.main:app --reload` 启动。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
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.analytics import router as analytics_router
|
||||
from app.api.v1.auth import router as auth_router
|
||||
@@ -21,17 +26,15 @@ 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_record import router as compare_record_router
|
||||
from app.api.v1.coupon import router as coupon_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.device import router as device_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.meituan import router as meituan_router
|
||||
from app.api.v1.notifications import router as notifications_router
|
||||
from app.api.v1.order import router as order_router
|
||||
from app.api.v1.platform import router as platform_router
|
||||
from app.api.v1.push import router as push_router
|
||||
from app.api.v1.report import router as report_router
|
||||
from app.api.v1.savings import router as savings_router
|
||||
from app.api.v1.signin import router as signin_router
|
||||
@@ -40,15 +43,16 @@ from app.api.v1.user import router as user_router
|
||||
from app.api.v1.wallet import router as wallet_router
|
||||
from app.api.v1.wxpay import router as wxpay_router
|
||||
from app.core.config import settings
|
||||
from app.core.heartbeat_monitor_worker import (
|
||||
start_heartbeat_monitor,
|
||||
stop_heartbeat_monitor,
|
||||
)
|
||||
from app.core.daily_exchange_worker import (
|
||||
start_daily_exchange_worker,
|
||||
stop_daily_exchange_worker,
|
||||
)
|
||||
from app.core.heartbeat_monitor_worker import (
|
||||
start_heartbeat_monitor,
|
||||
stop_heartbeat_monitor,
|
||||
)
|
||||
from app.core.logging import setup_logging
|
||||
from app.core.pricebot_client import aclose_pricebot_client, get_pricebot_client
|
||||
from app.core.withdraw_reconcile_worker import (
|
||||
start_withdraw_reconcile_worker,
|
||||
stop_withdraw_reconcile_worker,
|
||||
@@ -68,6 +72,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
settings.APP_DEBUG,
|
||||
settings.DATABASE_URL.split("://", 1)[0],
|
||||
)
|
||||
get_pricebot_client() # 预热透传 client:把建 SSL 上下文的一次性成本付在启动,首个领券请求即热
|
||||
reconcile_task = start_withdraw_reconcile_worker()
|
||||
heartbeat_task = start_heartbeat_monitor()
|
||||
daily_exchange_task = start_daily_exchange_worker()
|
||||
@@ -77,6 +82,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
await stop_heartbeat_monitor(heartbeat_task)
|
||||
await stop_withdraw_reconcile_worker(reconcile_task)
|
||||
await stop_daily_exchange_worker(daily_exchange_task)
|
||||
await aclose_pricebot_client()
|
||||
logger.info("shutting down")
|
||||
|
||||
|
||||
@@ -122,6 +128,10 @@ app.include_router(savings_router)
|
||||
app.include_router(ad_router)
|
||||
app.include_router(order_router)
|
||||
app.include_router(report_router)
|
||||
# 消息通知中心(PRD;虚拟数据阶段:mock 数据在内存,见 repositories/notification_mock.py)
|
||||
app.include_router(notifications_router)
|
||||
# 厂商推送测试三件套(配置状态/模板预览/测试发送,支持 mock 与真发)
|
||||
app.include_router(push_router)
|
||||
# 内部(server→server)端点:pricebot 上报价格观测 / 店铺映射,靠共享密钥头校验,不对客户端开放。
|
||||
app.include_router(internal_price_router)
|
||||
app.include_router(internal_store_router)
|
||||
@@ -134,6 +144,30 @@ app.include_router(cps_redirect_router)
|
||||
# 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。
|
||||
_media_root = Path(settings.MEDIA_ROOT)
|
||||
_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(
|
||||
settings.MEDIA_URL_PREFIX,
|
||||
StaticFiles(directory=str(_media_root)),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""所有 ORM model 必须在这里 import 一次,Alembic / metadata 才能扫到。"""
|
||||
from app.models.ad_ecpm import AdEcpmRecord # 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_watch_log import AdWatchLog # noqa: F401
|
||||
from app.models.admin import AdminAuditLog, AdminUser # noqa: F401
|
||||
@@ -18,6 +19,7 @@ from app.models.coupon_state import ( # noqa: F401
|
||||
CouponClaimRecord,
|
||||
CouponDailyCompletion,
|
||||
CouponPromptEngagement,
|
||||
CouponSession,
|
||||
)
|
||||
from app.models.feedback import Feedback # noqa: F401
|
||||
from app.models.invite import InviteRelation # noqa: F401
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""穿山甲 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,3 +181,79 @@ class CouponPromptEngagement(Base):
|
||||
f"<CouponPromptEngagement device={self.device_id} "
|
||||
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}>"
|
||||
)
|
||||
|
||||
+22
-4
@@ -1,11 +1,13 @@
|
||||
"""CPS 对账订单(cps_order)。
|
||||
|
||||
从美团联盟 query_order 按时间窗拉回、按 sid 归群的订单明细。字段对齐 query_order
|
||||
从联盟 API 按时间窗拉回、按平台落库的 CPS 订单明细。字段最初对齐美团 query_order,
|
||||
后续兼容京东订单报表:
|
||||
实测返回:
|
||||
- payPrice / profit 是「元」字符串 → 入库统一转「分」(与全站口径一致)
|
||||
- payTime / updateTime 是秒级时间戳 → 入库转 tz-aware datetime
|
||||
- status: 2付款 3完成 4取消 5风控 6结算(取消/风控不计佣金)
|
||||
order_id 全局唯一,reconcile 按它 upsert(订单状态会变,重复拉则更新)。
|
||||
order_id 全局唯一,reconcile 按它 upsert(订单状态会变,重复拉则更新)。京东订单用
|
||||
`jd:<row_id>` 前缀避免与美团订单号碰撞。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -22,8 +24,13 @@ class CpsOrder(Base):
|
||||
__tablename__ = "cps_order"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
# 美团订单号(加密串),全局唯一,upsert 幂等键。
|
||||
# 平台:meituan / jd。历史数据迁移默认 meituan。
|
||||
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)
|
||||
# 平台原始订单号/行号。京东一笔订单多 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: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
act_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
@@ -35,11 +42,21 @@ class CpsOrder(Base):
|
||||
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_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结算
|
||||
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)
|
||||
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(
|
||||
DateTime(timezone=True), index=True, nullable=True
|
||||
@@ -59,5 +76,6 @@ class CpsOrder(Base):
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
f"<CpsOrder id={self.id} order_id={self.order_id!r} "
|
||||
f"sid={self.sid!r} status={self.mt_status} profit_cents={self.commission_cents}>"
|
||||
f"platform={self.platform!r} sid={self.sid!r} "
|
||||
f"status={self.mt_status or self.jd_valid_code} profit_cents={self.commission_cents}>"
|
||||
)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""设备表(无障碍保护存活检测 + 极光推送)。
|
||||
"""设备表(无障碍保护存活检测 + 厂商直推)。
|
||||
|
||||
每条 = 一个用户的一台设备(per-install,device_id 由客户端 DeviceId.get() 生成)。
|
||||
客户端的无障碍服务存活时周期上报心跳刷新 last_heartbeat_at;App 前台/登录时上报
|
||||
registration_id(极光推送目标)。后端 heartbeat_monitor_worker 扫描「曾经保护过、
|
||||
现在心跳超时」的设备,通过极光推送提醒用户重开无障碍。
|
||||
push_vendor + push_token(厂商推送目标)。后端 heartbeat_monitor_worker 扫描「曾经保护过、
|
||||
现在心跳超时」的设备,通过厂商直推提醒用户重开无障碍。
|
||||
|
||||
liveness_state 状态机(防刷屏,一次掉线只推一条):
|
||||
unknown → alive(收到 service 心跳)→ silent/notified(扫描发现超时并已推送)
|
||||
@@ -30,7 +30,7 @@ from app.db.base import Base
|
||||
|
||||
class DeviceLiveness(Base):
|
||||
# 表名不叫 device:device 易被当成「设备信息(品牌/型号/系统)」表;本表实为**无障碍存活监控状态**
|
||||
# (心跳 last_heartbeat_at + liveness_state + kill_alert_pending + 推送目标 registration_id),故名 device_liveness。
|
||||
# (心跳 last_heartbeat_at + liveness_state + kill_alert_pending + 厂商推送目标),故名 device_liveness。
|
||||
__tablename__ = "device_liveness"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "device_id", name="uq_device_liveness_user_device"),
|
||||
@@ -42,8 +42,12 @@ class DeviceLiveness(Base):
|
||||
)
|
||||
# 客户端 DeviceId.get() 生成的 per-install id(如 device_Pixel_ab12cd34)
|
||||
device_id: Mapped[str] = mapped_column(String(128), index=True, nullable=False)
|
||||
# 极光推送 registration id;拿到才填(JCollectionAuth 同意后才下发)
|
||||
# 旧极光推送 registration id,仅为兼容历史客户端/数据保留;新链路使用 push_vendor + push_token。
|
||||
registration_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# 厂商推送类型:honor/vivo/xiaomi/oppo 等;客户端按实际 SDK token 来源上报。
|
||||
push_vendor: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
# 厂商 push token / regId / registration_id;不同厂商命名不同,后端统一存这里。
|
||||
push_token: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
platform: Mapped[str] = mapped_column(String(16), nullable=False, default="android")
|
||||
app_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
|
||||
|
||||
@@ -23,14 +23,30 @@ class Feedback(Base):
|
||||
)
|
||||
content: Mapped[str] = mapped_column(Text, 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
|
||||
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(未采纳)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True)
|
||||
reject_reason: Mapped[str | None] = mapped_column(String(256), 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(内部备注,用户不可见)、reject_reason(未采纳原因)区分开。
|
||||
admin_reply: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
reviewed_by_admin_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("admin_user.id"), nullable=True
|
||||
)
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""穿山甲 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
|
||||
|
||||
import logging
|
||||
from datetime import date, datetime
|
||||
from datetime import date, datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
@@ -17,6 +17,7 @@ from app.models.coupon_state import (
|
||||
CouponClaimRecord,
|
||||
CouponDailyCompletion,
|
||||
CouponPromptEngagement,
|
||||
CouponSession,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.coupon_state")
|
||||
@@ -232,3 +233,91 @@ def sum_claimed_count(db: Session, user_id: int) -> int:
|
||||
)
|
||||
).scalar_one()
|
||||
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()
|
||||
|
||||
@@ -21,17 +21,23 @@ def register_or_update(
|
||||
*,
|
||||
user_id: int,
|
||||
device_id: str,
|
||||
registration_id: str | None,
|
||||
registration_id: str | None = None,
|
||||
push_vendor: str | None = None,
|
||||
push_token: str | None = None,
|
||||
platform: str = "android",
|
||||
app_version: str | None = None,
|
||||
) -> DeviceLiveness:
|
||||
"""注册设备或更新其 registration_id / 元信息。upsert by (user_id, device_id)。"""
|
||||
"""注册设备或更新其厂商 push token / 元信息。upsert by (user_id, device_id)。"""
|
||||
normalized_vendor = _normalize_push_vendor(push_vendor)
|
||||
normalized_token = push_token.strip() if push_token else None
|
||||
device = _get(db, user_id=user_id, device_id=device_id)
|
||||
if device is None:
|
||||
device = DeviceLiveness(
|
||||
user_id=user_id,
|
||||
device_id=device_id,
|
||||
registration_id=registration_id,
|
||||
push_vendor=normalized_vendor,
|
||||
push_token=normalized_token,
|
||||
platform=platform or "android",
|
||||
app_version=app_version,
|
||||
)
|
||||
@@ -39,6 +45,10 @@ def register_or_update(
|
||||
else:
|
||||
if registration_id:
|
||||
device.registration_id = registration_id
|
||||
if normalized_vendor:
|
||||
device.push_vendor = normalized_vendor
|
||||
if normalized_token:
|
||||
device.push_token = normalized_token
|
||||
if platform:
|
||||
device.platform = platform
|
||||
if app_version:
|
||||
@@ -54,7 +64,9 @@ def touch_heartbeat(
|
||||
user_id: int,
|
||||
device_id: str,
|
||||
accessibility_enabled: bool,
|
||||
registration_id: str | None,
|
||||
registration_id: str | None = None,
|
||||
push_vendor: str | None = None,
|
||||
push_token: str | None = None,
|
||||
) -> DeviceLiveness:
|
||||
"""处理一次心跳(心跳也能自注册)。
|
||||
|
||||
@@ -69,6 +81,12 @@ def touch_heartbeat(
|
||||
|
||||
if registration_id:
|
||||
device.registration_id = registration_id
|
||||
normalized_vendor = _normalize_push_vendor(push_vendor)
|
||||
normalized_token = push_token.strip() if push_token else None
|
||||
if normalized_vendor:
|
||||
device.push_vendor = normalized_vendor
|
||||
if normalized_token:
|
||||
device.push_token = normalized_token
|
||||
device.last_report_protection_on = accessibility_enabled
|
||||
|
||||
if accessibility_enabled:
|
||||
@@ -87,7 +105,7 @@ def touch_heartbeat(
|
||||
def list_overdue(db: Session, *, timeout_minutes: int) -> list[DeviceLiveness]:
|
||||
"""掉线设备:曾经保护过、当前 alive、心跳超时。
|
||||
|
||||
本期只做终端打印检测、不推送 → 不再要求有 registration_id(没接极光 token 的设备也要检出)。
|
||||
即使没有厂商 token 也要检出,后续由 kill_alert_pending 走客户端进 App 后兜底提醒。
|
||||
"""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=timeout_minutes)
|
||||
stmt = select(DeviceLiveness).where(
|
||||
@@ -124,3 +142,27 @@ def ack_kill_alert(db: Session, *, user_id: int, device_id: str) -> None:
|
||||
if device is not None and device.kill_alert_pending:
|
||||
device.kill_alert_pending = False
|
||||
db.commit()
|
||||
|
||||
|
||||
def has_push_target(device: DeviceLiveness | None) -> bool:
|
||||
"""是否已有厂商直推所需的 vendor + token。"""
|
||||
return bool(device and device.push_vendor and device.push_token)
|
||||
|
||||
|
||||
def _normalize_push_vendor(push_vendor: str | None) -> str | None:
|
||||
if not push_vendor:
|
||||
return None
|
||||
vendor = push_vendor.strip().lower()
|
||||
aliases = {
|
||||
"honor": "honor",
|
||||
"hihonor": "honor",
|
||||
"荣耀": "honor",
|
||||
"vivo": "vivo",
|
||||
"xiaomi": "xiaomi",
|
||||
"mi": "xiaomi",
|
||||
"小米": "xiaomi",
|
||||
"oppo": "oppo",
|
||||
"oneplus": "oppo",
|
||||
"realme": "oppo",
|
||||
}
|
||||
return aliases.get(vendor, vendor)
|
||||
|
||||
@@ -17,12 +17,24 @@ def create_feedback(
|
||||
content: str,
|
||||
contact: str,
|
||||
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:
|
||||
fb = Feedback(
|
||||
user_id=user_id,
|
||||
content=content,
|
||||
contact=contact,
|
||||
source=source,
|
||||
scene=scene 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",
|
||||
created_at=datetime.now(CN_TZ).replace(tzinfo=None),
|
||||
)
|
||||
|
||||
@@ -216,13 +216,21 @@ def try_reward_on_compare(db: Session, invitee_user_id: int) -> CompareRewardRes
|
||||
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 /
|
||||
try_reward_on_compare);保留返回位兼容旧响应字段 coins_earned。
|
||||
"""
|
||||
count = db.execute(
|
||||
select(func.count())
|
||||
.select_from(InviteRelation)
|
||||
.where(InviteRelation.inviter_user_id == inviter_id)
|
||||
.where(
|
||||
InviteRelation.inviter_user_id == inviter_id,
|
||||
InviteRelation.compare_reward_granted.is_(True),
|
||||
)
|
||||
).scalar_one()
|
||||
coins = db.execute(
|
||||
select(func.coalesce(func.sum(InviteRelation.inviter_coin), 0))
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.launch_confirm_sample import LaunchConfirmSample
|
||||
@@ -33,3 +35,27 @@ def insert_sample(db: Session, payload: LaunchConfirmSampleIn) -> int:
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row.id
|
||||
|
||||
|
||||
def list_samples(
|
||||
db: Session,
|
||||
*,
|
||||
exec_success: Optional[bool] = None,
|
||||
host_package: Optional[str] = None,
|
||||
since: Optional[datetime] = None,
|
||||
limit: int = 1000,
|
||||
) -> list[LaunchConfirmSample]:
|
||||
"""按条件查样本(pricebot 的 distill_launch_confirm.py 沉淀工具读)。created_at 升序。
|
||||
|
||||
过滤项都可空:exec_success(只看放行成功的)/ host_package(只看某宿主包)/
|
||||
since(>= created_at)。limit 兜底防一次拉爆全表。
|
||||
"""
|
||||
stmt = select(LaunchConfirmSample)
|
||||
if exec_success is not None:
|
||||
stmt = stmt.where(LaunchConfirmSample.exec_success == exec_success)
|
||||
if host_package:
|
||||
stmt = stmt.where(LaunchConfirmSample.host_package == host_package)
|
||||
if since is not None:
|
||||
stmt = stmt.where(LaunchConfirmSample.created_at >= since)
|
||||
stmt = stmt.order_by(LaunchConfirmSample.created_at.asc()).limit(limit)
|
||||
return list(db.execute(stmt).scalars().all())
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
"""消息通知中心 mock 数据仓库(内存版,不落库)。
|
||||
|
||||
⚠️ 虚拟数据阶段专用:
|
||||
- 每个用户**首次访问时**生成一套固定样例(覆盖全部 13 种类型 + 「今天/昨天/当年/跨年」
|
||||
四种时间显示分支 + 已读/未读混合),供前端联调列表渲染、角标、已读消除全流程。
|
||||
- 已读状态维护在进程内存 → **重启服务即复位**;多 worker 部署时各 worker 各一份
|
||||
(联调期单 worker 跑即可,与 core/ratelimit.py 同款局限)。
|
||||
- 后续接真实数据:建 notification 表 + 本文件同名函数改为查库,API 层无需改动。
|
||||
|
||||
排序规则:**全列表按时间倒序**(最新在前),不做分类分组(需求方 2026-07-14 确认
|
||||
PRD §1 的"按分类分组"写错了,已取消);分页作用在排序后的整表上。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.core import notification_catalog as catalog
|
||||
|
||||
# 北京时间:sentAt 统一带 +08:00 偏移下发,前端直接按本地时区渲染「今天/昨天/M月D日」。
|
||||
_CST = timezone(timedelta(hours=8))
|
||||
|
||||
# mock id 从 90001 起步,一眼可辨"这是虚拟数据"(真实表主键自增不会从这里开始)。
|
||||
_id_counter = itertools.count(90001)
|
||||
_lock = threading.Lock()
|
||||
# user_id → 该用户的 mock 通知列表(生成后仅 is_read 会变化)
|
||||
_stores: dict[int, list[MockNotification]] = {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockNotification:
|
||||
"""一条 mock 通知的动态部分;静态部分(标题/分类/版式/操作行)看 catalog.TYPES。"""
|
||||
|
||||
id: int
|
||||
type_key: str
|
||||
sent_at: datetime
|
||||
is_read: bool
|
||||
coins: int | None = None # 金币数(整数;dual_amount / coin_reward 卡)
|
||||
cash_cents: int | None = None # 现金金额,单位分(dual_amount / withdraw / friend_cash 卡)
|
||||
info_rows: list[dict[str, str]] = field(default_factory=list) # [{label, value}]
|
||||
extra: dict[str, str] = field(default_factory=dict) # 点击跳转所需业务参数
|
||||
|
||||
|
||||
def cash_yuan(cents: int | None) -> str | None:
|
||||
"""分 → 保留两位小数的元字符串(PRD §3:现金/提现金额保留两位小数)。"""
|
||||
if cents is None:
|
||||
return None
|
||||
return f"{cents // 100}.{cents % 100:02d}"
|
||||
|
||||
|
||||
def _fmt_time(dt: datetime) -> str:
|
||||
"""信息行里「到账时间」等 value 的展示格式。"""
|
||||
return dt.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 各类型的样例卡片内容(数值 + 信息行 + extra),文案对齐 PRD §3
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _card_reward_expiring(sent_at: datetime, coins: int = 86, cash: int = 1280, days: int = 3) -> dict:
|
||||
return {
|
||||
"coins": coins,
|
||||
"cash_cents": cash,
|
||||
"info_rows": [
|
||||
{
|
||||
"label": "过期说明",
|
||||
"value": f"您有{coins}金币和{cash_yuan(cash)}元现金即将失效,"
|
||||
"完成一次一键领券或一键比价即可激活收益",
|
||||
},
|
||||
{"label": "过期时间", "value": f"{days}天后失效"},
|
||||
],
|
||||
# batchId:同一批次激活成功后不再重复推送(PRD §2 激活逻辑)
|
||||
"extra": {"batchId": f"batch_{sent_at:%Y%m%d}"},
|
||||
}
|
||||
|
||||
|
||||
def _card_reward_expired(sent_at: datetime, coins: int = 35, cash: int = 60) -> dict:
|
||||
return {
|
||||
"coins": coins,
|
||||
"cash_cents": cash,
|
||||
"info_rows": [
|
||||
{
|
||||
"label": "过期说明",
|
||||
"value": f"您的{coins}金币和{cash_yuan(cash)}元现金已失效,"
|
||||
"完成一次一键领券或一键比价可赚取新收益",
|
||||
},
|
||||
{"label": "过期时间", "value": f"已过期 {sent_at.month}月{sent_at.day}日失效"},
|
||||
],
|
||||
"extra": {}, # 点击跳赚钱页(tab),无需参数
|
||||
}
|
||||
|
||||
|
||||
def _card_withdraw_success(sent_at: datetime, cash: int = 50) -> dict:
|
||||
return {
|
||||
"cash_cents": cash,
|
||||
"info_rows": [
|
||||
{"label": "到账账户", "value": "微信钱包"},
|
||||
{"label": "到账时间", "value": _fmt_time(sent_at)},
|
||||
],
|
||||
"extra": {}, # 无跳转,仅消红点
|
||||
}
|
||||
|
||||
|
||||
def _card_withdraw_failed(sent_at: datetime, cash: int = 350, reason: str = "微信零钱未实名") -> dict:
|
||||
return {
|
||||
"cash_cents": cash,
|
||||
"info_rows": [
|
||||
{"label": "失败原因", "value": reason},
|
||||
{"label": "退回说明", "value": "款项已原路退回现金余额"},
|
||||
],
|
||||
"extra": {"withdrawId": "88001"}, # 点击跳提现页
|
||||
}
|
||||
|
||||
|
||||
def _card_permission(permission: str) -> dict:
|
||||
# permission ∈ accessibility(无障碍)/ battery(省电策略)/ autostart(自启动)/ overlay(悬浮窗)
|
||||
# 客户端点击时按此 key 实时检测该权限并弹对应开启弹窗(PRD §2 权限逻辑)。
|
||||
return {
|
||||
"info_rows": [
|
||||
{"label": "说明文案", "value": "未开启将导致核心功能不可用,请尽快开启"},
|
||||
],
|
||||
"extra": {"permission": permission},
|
||||
}
|
||||
|
||||
|
||||
def _card_feedback_reply(feedback_id: str) -> dict:
|
||||
return {
|
||||
"info_rows": [
|
||||
{"label": "说明文案", "value": "快去看看官方给您的回复吧~"},
|
||||
],
|
||||
"extra": {"feedbackId": feedback_id}, # 跳反馈历史页并滚动高亮该条(PRD §2)
|
||||
}
|
||||
|
||||
|
||||
def _card_feedback_reward(sent_at: datetime, coins: int = 300,
|
||||
reply: str = "感谢反馈,您说的问题已经修复上线,送您的金币请查收~") -> dict:
|
||||
return {
|
||||
"coins": coins,
|
||||
"info_rows": [
|
||||
{"label": "奖励说明", "value": "感谢您的反馈,您的金币奖励已到账"},
|
||||
{"label": "官方留言", "value": reply}, # PRD §3:官方留言必填(发奖励必带留言)
|
||||
{"label": "到账时间", "value": _fmt_time(sent_at)},
|
||||
],
|
||||
"extra": {"feedbackId": "3002"},
|
||||
}
|
||||
|
||||
|
||||
def _card_report_approved(sent_at: datetime, coins: int = 1000, store: str = "蜀大侠火锅") -> dict:
|
||||
return {
|
||||
"coins": coins,
|
||||
"info_rows": [
|
||||
{"label": "奖励说明", "value": f"您爆料的「{store}」更低价已通过审核,金币奖励已到账"},
|
||||
{"label": "到账时间", "value": _fmt_time(sent_at)},
|
||||
],
|
||||
"extra": {"reportId": "5001"}, # 跳爆料记录页并滚动高亮该条
|
||||
}
|
||||
|
||||
|
||||
def _card_invite_order_reward(sent_at: datetime, cash: int = 200, nickname: str = "柚子") -> dict:
|
||||
return {
|
||||
"cash_cents": cash,
|
||||
"info_rows": [
|
||||
{"label": "奖励说明", "value": f"好友「{nickname}」完成首次下单"},
|
||||
{"label": "到账时间", "value": _fmt_time(sent_at)},
|
||||
],
|
||||
"extra": {"inviteeNickname": nickname}, # 跳邀请页(welfare/invite.html?from=notifications)
|
||||
}
|
||||
|
||||
|
||||
def _card_invite_remind(nickname: str = "阿泽") -> dict:
|
||||
return {
|
||||
"info_rows": [
|
||||
{
|
||||
"label": "说明文案",
|
||||
"value": f"好友「{nickname}」已注册,还没完成比价下单,提醒TA完成后你可得2元现金",
|
||||
},
|
||||
],
|
||||
# scrollTo=remind:跳邀请页并自动滚动到底部「提醒好友」模块(PRD §2 #13)
|
||||
"extra": {"inviteeNickname": nickname, "scrollTo": "remind"},
|
||||
}
|
||||
|
||||
|
||||
def build_sample(type_key: str, sent_at: datetime | None = None) -> MockNotification:
|
||||
"""按类型生成一条默认样例(push 测试联动等场景插入新 mock 通知用)。"""
|
||||
catalog.get_type(type_key) # 校验 type 合法
|
||||
now = sent_at or datetime.now(_CST)
|
||||
default_builders = {
|
||||
"reward_expiring": lambda: _card_reward_expiring(now),
|
||||
"reward_expired": lambda: _card_reward_expired(now),
|
||||
"withdraw_success": lambda: _card_withdraw_success(now),
|
||||
"withdraw_failed": lambda: _card_withdraw_failed(now),
|
||||
"perm_accessibility": lambda: _card_permission("accessibility"),
|
||||
"perm_battery": lambda: _card_permission("battery"),
|
||||
"perm_autostart": lambda: _card_permission("autostart"),
|
||||
"perm_overlay": lambda: _card_permission("overlay"),
|
||||
"feedback_reply": lambda: _card_feedback_reply("3001"),
|
||||
"feedback_reward": lambda: _card_feedback_reward(now),
|
||||
"report_approved": lambda: _card_report_approved(now),
|
||||
"invite_order_reward": lambda: _card_invite_order_reward(now),
|
||||
"invite_remind": lambda: _card_invite_remind(),
|
||||
}
|
||||
return MockNotification(
|
||||
id=next(_id_counter),
|
||||
type_key=type_key,
|
||||
sent_at=now,
|
||||
is_read=False,
|
||||
**default_builders[type_key](),
|
||||
)
|
||||
|
||||
|
||||
def _seed_user(user_id: int) -> list[MockNotification]:
|
||||
"""给一个用户生成整套 mock 样例:13 类型全覆盖 + 时间显示四分支 + 已读/未读混合。"""
|
||||
now = datetime.now(_CST)
|
||||
today = now - timedelta(hours=2) # 今天
|
||||
today_recent = now - timedelta(minutes=10) # 今天(更近,验证组内时间倒序)
|
||||
yesterday = now - timedelta(days=1) # 昨天
|
||||
this_year = now - timedelta(days=40) # 当年 → 「M月D日」
|
||||
last_year = now - timedelta(days=370) # 跨年 → 「YYYY年M月D日」
|
||||
|
||||
def one(type_key: str, sent_at: datetime, card: dict, *, read: bool = False) -> MockNotification:
|
||||
return MockNotification(
|
||||
id=next(_id_counter), type_key=type_key, sent_at=sent_at, is_read=read, **card
|
||||
)
|
||||
|
||||
return [
|
||||
# ---- 提现助手(4 类 + 2 条补充,验证组内倒序与跨年展示)----
|
||||
one("reward_expiring", today, _card_reward_expiring(today)),
|
||||
one("reward_expired", yesterday, _card_reward_expired(yesterday)),
|
||||
one("withdraw_success", today_recent, _card_withdraw_success(today_recent)),
|
||||
one("withdraw_failed", yesterday, _card_withdraw_failed(yesterday)),
|
||||
one("withdraw_success", now - timedelta(days=3),
|
||||
_card_withdraw_success(now - timedelta(days=3), cash=3000)),
|
||||
one("withdraw_success", last_year, _card_withdraw_success(last_year, cash=120), read=True),
|
||||
# ---- 系统通知(权限异常 ×4;同一权限未读期间只更新时间不新增,故各一条)----
|
||||
one("perm_accessibility", now - timedelta(hours=1), _card_permission("accessibility")),
|
||||
one("perm_battery", now - timedelta(days=3), _card_permission("battery"), read=True),
|
||||
one("perm_autostart", now - timedelta(days=5), _card_permission("autostart")),
|
||||
one("perm_overlay", now - timedelta(days=6), _card_permission("overlay"), read=True),
|
||||
# ---- 我的反馈 ----
|
||||
one("feedback_reply", now - timedelta(hours=4), _card_feedback_reply("3001")),
|
||||
one("feedback_reward", yesterday, _card_feedback_reward(yesterday)),
|
||||
one("feedback_reply", now - timedelta(days=60), _card_feedback_reply("2001"), read=True),
|
||||
# ---- 我的爆料 ----
|
||||
one("report_approved", this_year, _card_report_approved(this_year)),
|
||||
# ---- 好友邀请 ----
|
||||
one("invite_order_reward", today_recent, _card_invite_order_reward(today_recent)),
|
||||
one("invite_remind", now - timedelta(days=2), _card_invite_remind()),
|
||||
]
|
||||
|
||||
|
||||
def _get_store(user_id: int) -> list[MockNotification]:
|
||||
"""取该用户的 mock 列表,首次访问时生成。调用方须持有 _lock。"""
|
||||
store = _stores.get(user_id)
|
||||
if store is None:
|
||||
store = _seed_user(user_id)
|
||||
_stores[user_id] = store
|
||||
return store
|
||||
|
||||
|
||||
def _sorted(store: list[MockNotification]) -> list[MockNotification]:
|
||||
"""排序:全列表时间倒序(同秒再按 id 倒序稳定化)。不分组(PRD 原文写错,已取消)。"""
|
||||
return sorted(store, key=lambda n: (-n.sent_at.timestamp(), -n.id))
|
||||
|
||||
|
||||
def list_notifications(
|
||||
user_id: int, *, page: int, page_size: int
|
||||
) -> tuple[list[MockNotification], int, int]:
|
||||
"""分页取通知列表。返回 (当前页条目, 总条数, 未读条数)。"""
|
||||
with _lock:
|
||||
store = _get_store(user_id)
|
||||
ordered = _sorted(store)
|
||||
total = len(ordered)
|
||||
unread = sum(1 for n in store if not n.is_read)
|
||||
start = (page - 1) * page_size
|
||||
return ordered[start : start + page_size], total, unread
|
||||
|
||||
|
||||
def unread_count(user_id: int) -> int:
|
||||
"""未读总数(首页铃铛角标)。"""
|
||||
with _lock:
|
||||
return sum(1 for n in _get_store(user_id) if not n.is_read)
|
||||
|
||||
|
||||
def mark_read(
|
||||
user_id: int, *, ids: list[int] | None = None, mark_all: bool = False
|
||||
) -> tuple[int, int]:
|
||||
"""标记已读。mark_all=True 全量清零,否则按 ids 逐条置读(不存在的 id 忽略,幂等)。
|
||||
|
||||
返回 (本次实际由未读→已读的条数, 剩余未读数)。
|
||||
"""
|
||||
with _lock:
|
||||
store = _get_store(user_id)
|
||||
marked = 0
|
||||
if mark_all:
|
||||
for n in store:
|
||||
if not n.is_read:
|
||||
n.is_read = True
|
||||
marked += 1
|
||||
else:
|
||||
wanted = set(ids or [])
|
||||
for n in store:
|
||||
if n.id in wanted and not n.is_read:
|
||||
n.is_read = True
|
||||
marked += 1
|
||||
unread = sum(1 for n in store if not n.is_read)
|
||||
return marked, unread
|
||||
|
||||
|
||||
def insert_sample(user_id: int, type_key: str) -> MockNotification:
|
||||
"""插入一条该类型的新 mock 通知(未读)。push 测试联动用:push extras 带上它的 id,
|
||||
客户端点击 push 后调 POST /notifications/read {ids:[id]} 即可闭环验证已读联动。"""
|
||||
with _lock:
|
||||
store = _get_store(user_id)
|
||||
item = build_sample(type_key)
|
||||
store.append(item)
|
||||
return item
|
||||
|
||||
|
||||
def reset_store(user_id: int | None = None) -> None:
|
||||
"""清空 mock 状态(测试/联调用;user_id=None 清全部)。下次访问重新生成。"""
|
||||
with _lock:
|
||||
if user_id is None:
|
||||
_stores.clear()
|
||||
else:
|
||||
_stores.pop(user_id, None)
|
||||
@@ -610,6 +610,7 @@ def create_withdraw(
|
||||
source: str = "coin_cash",
|
||||
user_name: str | None = None,
|
||||
out_bill_no: str | None = None,
|
||||
allow_sub_min: bool = False,
|
||||
) -> WithdrawOrder:
|
||||
"""发起提现:原子扣款 + 建单 reviewing(待人工审核),**不打款**。
|
||||
|
||||
@@ -619,8 +620,13 @@ def create_withdraw(
|
||||
重复发起多笔提现(审核拒绝再退回)。
|
||||
#2 out_bill_no 客户端幂等键:同号重试返回该单现状(reviewing 等审核),不重复扣款建单。
|
||||
实名 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。
|
||||
"""
|
||||
if amount_cents < rewards.get_withdraw_min_cents(db) or amount_cents > rewards.get_withdraw_max_cents(db):
|
||||
min_c = 0 if allow_sub_min else rewards.get_withdraw_min_cents(db)
|
||||
if amount_cents < min_c or amount_cents > rewards.get_withdraw_max_cents(db):
|
||||
raise InvalidWithdrawAmountError
|
||||
|
||||
# 提现即要求已绑微信:否则审核通过也打不了款,提前拦更友好
|
||||
|
||||
@@ -123,6 +123,7 @@ class ComparisonRecordIn(BaseModel):
|
||||
# pricebot done.params.trace_url 原样上报,落库供记录页「复制调试链接」(dir 名含落盘
|
||||
# 时分秒前端拼不出,必须由后端透传)。
|
||||
trace_url: str | None = Field(None, description="本次比价公网调试链接")
|
||||
total_ms: int | None = Field(None, description="整场比价墙钟耗时(ms)")
|
||||
|
||||
# ===== debug 维度(客户端采集上报;旧客户端不带 → None。仅 admin 比价记录页用)=====
|
||||
# 必须显式声明,否则 model_dump() 落 raw_payload 时被 pydantic 静默丢弃(同上面 coupon_saved 的坑)。
|
||||
@@ -172,6 +173,7 @@ class ComparisonRecordOut(BaseModel):
|
||||
items: list = []
|
||||
comparison_results: list = []
|
||||
skipped_dish_names: list = []
|
||||
total_ms: int | None = None
|
||||
# 「已下单」(店级):该店名在该用户真实下单(source='compare')里出现过即 True。
|
||||
# 由 list_records 动态算出挂在 ORM 实例上(非 DB 列),from_attributes 读出;缺省 False。
|
||||
ordered: bool = False
|
||||
|
||||
@@ -49,3 +49,28 @@ class CouponStatsOut(BaseModel):
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
+22
-1
@@ -3,12 +3,15 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class DeviceRegisterRequest(BaseModel):
|
||||
device_id: str
|
||||
# registration_id 为旧极光字段,新推送链路统一使用 push_vendor + push_token。
|
||||
registration_id: str | None = None
|
||||
push_vendor: str | None = None
|
||||
push_token: str | None = None
|
||||
platform: str = "android"
|
||||
app_version: str | None = None
|
||||
|
||||
@@ -18,6 +21,8 @@ class HeartbeatRequest(BaseModel):
|
||||
source: str = "service" # service | app
|
||||
accessibility_enabled: bool = True
|
||||
registration_id: str | None = None
|
||||
push_vendor: str | None = None
|
||||
push_token: str | None = None
|
||||
|
||||
|
||||
class DeviceOut(BaseModel):
|
||||
@@ -26,6 +31,8 @@ class DeviceOut(BaseModel):
|
||||
id: int
|
||||
device_id: str
|
||||
registration_id: str | None
|
||||
push_vendor: str | None
|
||||
push_token: str | None
|
||||
ever_protected: bool
|
||||
liveness_state: str
|
||||
last_heartbeat_at: datetime | None
|
||||
@@ -46,3 +53,17 @@ class LivenessOut(BaseModel):
|
||||
|
||||
class LivenessAckRequest(BaseModel):
|
||||
device_id: str
|
||||
|
||||
|
||||
class PushTestRequest(BaseModel):
|
||||
device_id: str
|
||||
delay_seconds: int = Field(default=10, ge=0, le=60)
|
||||
push_vendor: str | None = None
|
||||
push_token: str | None = None
|
||||
registration_id: str | None = None
|
||||
|
||||
|
||||
class PushTestOut(BaseModel):
|
||||
ok: bool = True
|
||||
delay_seconds: int
|
||||
has_push_token: bool
|
||||
|
||||
@@ -30,10 +30,14 @@ class FeedbackQrConfigOut(BaseModel):
|
||||
class FeedbackRecordOut(BaseModel):
|
||||
id: int
|
||||
content: str
|
||||
# 比价反馈的问题场景(找错商品/优惠不对…);普通反馈为 None
|
||||
scene: str | None = None
|
||||
images: list[str] = Field(default_factory=list)
|
||||
status: str
|
||||
reject_reason: str | None = None
|
||||
reward_coins: int | None = None
|
||||
# 运营给用户的回复留言(用户端可见,采纳/未采纳都可能有)
|
||||
admin_reply: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""启动确认窗兜底样本的内部上报模型(pricebot → app-server)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class LaunchConfirmSampleIn(BaseModel):
|
||||
@@ -24,3 +26,20 @@ class LaunchConfirmSampleOut(BaseModel):
|
||||
"""落库结果。"""
|
||||
|
||||
id: int
|
||||
|
||||
|
||||
class LaunchConfirmSampleRow(BaseModel):
|
||||
"""单条样本(列表读出;沉淀脚本 distill 用,payload 原样带出)。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
created_at: datetime
|
||||
trace_id: str | None = None
|
||||
device_id: str | None = None
|
||||
host_package: str | None = None
|
||||
target_app: str | None = None
|
||||
system_locale: str | None = None
|
||||
exec_success: bool = False
|
||||
dialog_title: str | None = None
|
||||
payload: dict | None = None
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""消息通知中心 请求/响应契约。
|
||||
|
||||
⚠️ 命名约定:本组接口按 PRD 前端契约使用 **camelCase**(sentAt / isRead / pageSize …),
|
||||
与库内其他 snake_case 接口不同——PRD 与前端原型(notifications.html)按 camelCase 对接,
|
||||
需求方接口清单亦明确写作 sentAt / isRead,故整组遵循之。响应序列化走 pydantic alias。
|
||||
|
||||
字段说明都写在 Field(description=...) 里,起服务后打开 /docs 即是给前端的在线文档。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic.alias_generators import to_camel
|
||||
|
||||
|
||||
class _CamelModel(BaseModel):
|
||||
"""出参统一 camelCase(alias);populate_by_name 允许服务端代码仍用 snake_case 构造。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
||||
|
||||
|
||||
class InfoRow(_CamelModel):
|
||||
"""卡片信息行(PRD §3「信息行」列),前端按 label: value 逐行渲染。"""
|
||||
|
||||
label: str = Field(description="行标签,如「过期说明」「到账账户」「失败原因」")
|
||||
value: str = Field(description="行内容(已按 PRD 文案拼好变量,前端直接展示)")
|
||||
|
||||
|
||||
class NotificationItem(_CamelModel):
|
||||
"""一条通知卡片。
|
||||
|
||||
卡片头部三要素:categoryLabel(分类标签)+ 未读红点(isRead=false 时展示)+ 时间(sentAt)。
|
||||
时间显示规则(前端处理):今天→「今天」;昨天→「昨天」;当年→「M月D日」;跨年→「YYYY年M月D日」。
|
||||
"""
|
||||
|
||||
id: int = Field(description="通知 id(未读消除、push 联动都用它)")
|
||||
category: str = Field(
|
||||
description="分类 key:withdraw_assistant=提现助手 / system=系统通知 / "
|
||||
"feedback=我的反馈 / report=我的爆料 / invite=好友邀请"
|
||||
)
|
||||
category_label: str = Field(description="分类中文标签(卡片头部直接展示)")
|
||||
type: str = Field(
|
||||
description="类型 key(13 种,决定点击行为,见 PRD §2):reward_expiring 即将失效 / "
|
||||
"reward_expired 已失效 / withdraw_success 提现成功 / withdraw_failed 提现失败 / "
|
||||
"perm_accessibility 无障碍异常 / perm_battery 省电策略异常 / "
|
||||
"perm_autostart 自启动异常 / perm_overlay 悬浮窗异常 / "
|
||||
"feedback_reply 官方回复 / feedback_reward 反馈奖励 / "
|
||||
"report_approved 爆料审核通过 / invite_order_reward 好友下单奖励 / "
|
||||
"invite_remind 好友催单提醒"
|
||||
)
|
||||
card_style: str = Field(
|
||||
description="卡片版式:dual_amount 双金额卡 / withdraw 提现卡 / plain_text 纯文本卡 / "
|
||||
"coin_reward 金币奖励卡 / friend_cash 好友现金卡"
|
||||
)
|
||||
title: str = Field(description="卡片标题(双金额/提现/金币奖励/好友现金卡标题居中)")
|
||||
coins: int | None = Field(
|
||||
default=None,
|
||||
description="金币数(整数,不带小数)。dual_amount / coin_reward 卡有值,其余 null",
|
||||
)
|
||||
cash_cents: int | None = Field(
|
||||
default=None,
|
||||
description="现金金额,单位【分】。dual_amount / withdraw / friend_cash 卡有值,其余 null",
|
||||
)
|
||||
cash_yuan: str | None = Field(
|
||||
default=None,
|
||||
description="现金金额展示串(元,保留两位小数,如 \"12.80\"),与 cashCents 同源,可直接展示",
|
||||
)
|
||||
info_rows: list[InfoRow] = Field(
|
||||
description="信息行列表(label: value),内容已按 PRD §3 拼好,前端逐行渲染即可"
|
||||
)
|
||||
action_text: str | None = Field(
|
||||
default=None,
|
||||
description="操作行文案(如「立即激活您的收益」「去开启」);null=无操作行(提现成功卡)。"
|
||||
"注意:点击目标是整张卡片,不区分卡片主体和操作行",
|
||||
)
|
||||
extra: dict[str, Any] = Field(
|
||||
description="点击跳转所需业务参数,按 type 取用:perm_* → {permission: accessibility|battery|"
|
||||
"autostart|overlay}(点击时实时检测该权限);feedback_* → {feedbackId};"
|
||||
"report_approved → {reportId};withdraw_failed → {withdrawId};"
|
||||
"invite_order_reward → {inviteeNickname};invite_remind → "
|
||||
"{inviteeNickname, scrollTo:\"remind\"};reward_expiring → {batchId}"
|
||||
)
|
||||
sent_at: datetime = Field(description="下发时间(ISO8601 带 +08:00 时区),前端按显示规则格式化")
|
||||
is_read: bool = Field(description="是否已读;false 时分类标签右侧展示 6px 红点(#E53935)")
|
||||
|
||||
|
||||
class NotificationListOut(_CamelModel):
|
||||
"""GET /api/v1/notifications 出参。列表已按时间倒序排好(最新在前,**不分组**;
|
||||
PRD §1 的"按分类分组"为笔误,已确认取消),前端无需再排。"""
|
||||
|
||||
items: list[NotificationItem] = Field(description="当前页通知卡片")
|
||||
page: int = Field(description="当前页码(1 起)")
|
||||
page_size: int = Field(description="每页条数")
|
||||
total: int = Field(description="全部通知总条数(含已读)")
|
||||
has_more: bool = Field(description="是否还有下一页")
|
||||
unread_count: int = Field(description="当前未读总数(与 /notifications/unread-count 同口径,省一次请求)")
|
||||
|
||||
|
||||
class UnreadCountOut(_CamelModel):
|
||||
"""GET /api/v1/notifications/unread-count 出参(首页铃铛角标)。"""
|
||||
|
||||
count: int = Field(description="未读总条数(精确值)")
|
||||
badge_text: str | None = Field(
|
||||
description="角标展示文案:超过 99 返回 \"99+\";等于 0 返回 null(整个角标隐藏,不展示空红点)"
|
||||
)
|
||||
|
||||
|
||||
class MarkReadRequest(_CamelModel):
|
||||
"""POST /api/v1/notifications/read 入参,两种模式二选一:
|
||||
|
||||
- `{"ids": [90001, 90002]}` 单条/多条置读——点击某张卡片、点击 push 落地后同步置读;
|
||||
- `{"all": true}` 全量清零——进入通知中心(或退出时)自动清零(PRD §4)。
|
||||
|
||||
同时传时 all=true 优先;不存在/已读的 id 自动忽略(幂等,可放心重试)。
|
||||
"""
|
||||
|
||||
ids: list[int] | None = Field(default=None, description="要置为已读的通知 id 列表")
|
||||
all: bool = Field(default=False, description="true=清空该用户全部未读")
|
||||
|
||||
|
||||
class MarkReadOut(_CamelModel):
|
||||
"""POST /api/v1/notifications/read 出参。"""
|
||||
|
||||
ok: bool = Field(description="固定 true(参数非法时走 400,不会到这里)")
|
||||
marked_count: int = Field(description="本次实际由未读变为已读的条数(重复请求会是 0)")
|
||||
unread_count: int = Field(description="处理后的剩余未读总数,可直接刷新铃铛角标")
|
||||
@@ -0,0 +1,99 @@
|
||||
"""厂商推送(测试/联调)接口契约。与消息中心同族,出参统一 camelCase。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic.alias_generators import to_camel
|
||||
|
||||
|
||||
class _CamelModel(BaseModel):
|
||||
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
||||
|
||||
|
||||
class PushVendorStatus(_CamelModel):
|
||||
vendor: str = Field(description="厂商 key:honor / huawei / xiaomi / oppo / vivo")
|
||||
label: str = Field(description="厂商中文名")
|
||||
configured: bool = Field(description="服务端凭据是否齐全(齐全才能真发,mock 不受影响)")
|
||||
missing_keys: list[str] = Field(description="缺失的 .env 配置键;configured=true 时为空")
|
||||
|
||||
|
||||
class PushVendorsOut(_CamelModel):
|
||||
vendors: list[PushVendorStatus] = Field(description="5 个厂商的配置状态")
|
||||
|
||||
|
||||
class PushTemplateOut(_CamelModel):
|
||||
type: str = Field(description="通知类型 key(13 种,与消息中心 type 一致)")
|
||||
category: str = Field(description="分类 key")
|
||||
category_label: str = Field(description="分类中文标签")
|
||||
card_style: str = Field(description="站内卡片版式")
|
||||
push_title: str = Field(description="push 标题(≤11 字固定文案,PRD §5)")
|
||||
push_body_sample: str = Field(description="push 正文示例(模板用 PRD 示例值渲染后的效果)")
|
||||
push_body_template: str = Field(description="push 正文模板原文,{var} 为变量占位")
|
||||
variables: list[str] = Field(description="模板变量名列表(调 /push/test 时可在 vars 里覆盖)")
|
||||
sample_vars: dict[str, str] = Field(description="各变量的 PRD 示例值(vars 未覆盖时的缺省)")
|
||||
|
||||
|
||||
class PushTemplatesOut(_CamelModel):
|
||||
templates: list[PushTemplateOut] = Field(description="13 种通知类型的 push 模板(PRD 编号顺序)")
|
||||
|
||||
|
||||
class PushTestRequest(_CamelModel):
|
||||
"""POST /api/v1/push/test 入参。三种发送内容来源(优先级从高到低):
|
||||
|
||||
1. 直接指定 title + content;
|
||||
2. 指定 type(13 种之一)→ 按 PRD §5 模板渲染,vars 可覆盖模板变量;
|
||||
3. 都不传 → 发一条通用测试文案。
|
||||
|
||||
推送目标:pushToken 直填,或 deviceId 反查该用户已注册设备(/api/v1/device/register 上报过的)。
|
||||
"""
|
||||
|
||||
vendor: str = Field(
|
||||
default="",
|
||||
description="厂商:honor/huawei/xiaomi/oppo/vivo(中文「华为」「小米」等别名也识别)。"
|
||||
"留空时用 deviceId 对应设备上报的 push_vendor",
|
||||
)
|
||||
push_token: str = Field(default="", description="厂商 push token / regId;留空则走 deviceId 反查")
|
||||
device_id: str = Field(default="", description="设备 id(客户端 DeviceId.get());用于反查 token")
|
||||
type: str = Field(
|
||||
default="",
|
||||
description="通知类型 key(13 种,见 GET /push/templates);留空且未直接给 title/content 时发通用测试文案",
|
||||
)
|
||||
vars: dict[str, str] = Field(
|
||||
default_factory=dict,
|
||||
description="覆盖 push 模板变量,如 {\"coins\":\"520\",\"cash\":\"6.66\"};缺省用 PRD 示例值",
|
||||
)
|
||||
title: str = Field(default="", description="直接指定标题(优先于 type 模板)")
|
||||
content: str = Field(default="", description="直接指定正文(优先于 type 模板)")
|
||||
create_notification: bool = Field(
|
||||
default=False,
|
||||
description="true=同时往该用户的消息中心 mock 列表插入一条同类型未读通知,push extras 带上它的"
|
||||
" notificationId → 可闭环验证「点 push → 落地 → 调 /notifications/read 消红点」联动"
|
||||
"(仅 type 为 13 种类型之一时生效)",
|
||||
)
|
||||
mock: bool = Field(
|
||||
default=True,
|
||||
description="true(默认)=不真调厂商 API,返回渲染结果(联调安全);false=真发,要求该厂商凭据已配置",
|
||||
)
|
||||
|
||||
|
||||
class PushTestOut(_CamelModel):
|
||||
ok: bool = Field(description="发送(或 mock 渲染)成功")
|
||||
mock: bool = Field(description="本次是否 mock(未真调厂商 API)")
|
||||
vendor: str = Field(description="实际使用的厂商 key(已归一化)")
|
||||
title: str = Field(description="实际下发的 push 标题")
|
||||
body: str = Field(description="实际下发的 push 正文")
|
||||
extras: dict[str, str] = Field(
|
||||
description="随 push 下发的自定义键值(客户端深链用):type 必有;createNotification=true 时带"
|
||||
" notificationId 及该通知的业务参数(feedbackId / permission / …)"
|
||||
)
|
||||
notification_id: int | None = Field(
|
||||
default=None, description="createNotification=true 时新插入的站内 mock 通知 id"
|
||||
)
|
||||
missing_keys: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="该厂商仍缺失的配置键(mock 发送时提示「真发前还需配什么」;真发时必为空)",
|
||||
)
|
||||
vendor_response: dict[str, Any] | None = Field(
|
||||
default=None, description="真发时厂商 API 的原始响应(mock 时为 null)"
|
||||
)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 847 KiB |
+13
-4
@@ -198,7 +198,7 @@
|
||||
选择「<span class="guide-highlight">在浏览器打开</span>」
|
||||
<span class="guide-final">在浏览器里按提示<span class="guide-target">去应用商店下载</span></span>
|
||||
</h2>
|
||||
<div class="guide-dismiss" id="wxGuideDismiss">我知道了 ✕</div>
|
||||
<!-- <div class="guide-dismiss" id="wxGuideDismiss">我知道了 ✕</div> -->
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||||
@@ -263,7 +263,7 @@
|
||||
var wxGuide = document.getElementById("wxGuide");
|
||||
function showWxGuide() { wxGuide.classList.add("show"); }
|
||||
function hideWxGuide() { wxGuide.classList.remove("show"); }
|
||||
document.getElementById("wxGuideDismiss").addEventListener("click", hideWxGuide);
|
||||
// document.getElementById("wxGuideDismiss").addEventListener("click", hideWxGuide); // 「我知道了 ✕」已注释隐藏
|
||||
if (isWeChat) showWxGuide(); // 微信里一进页面就提示去浏览器(微信内下载必被拦)
|
||||
|
||||
function showToast(text) {
|
||||
@@ -273,15 +273,24 @@
|
||||
showToast.timer = setTimeout(function () { toast.classList.remove("show"); }, 1400);
|
||||
}
|
||||
|
||||
// ===== 下载按钮:微信内引导去浏览器;iOS 提示;安卓写邀请码 + 跳应用商店 =====
|
||||
// ===== 应用商店下载:微信内引导去浏览器;iOS 提示;安卓写邀请码 + 跳应用商店 =====
|
||||
function handleDownload() {
|
||||
if (isWeChat) { showWxGuide(); return; }
|
||||
if (isIOS) { alert("iOS 版即将上线,请前往 App Store 搜索「傻瓜比价」"); return; }
|
||||
copyInviteCode(); // 先把邀请码写进剪贴板(供 App 首启归因),链路丢了还有 landing-track 指纹兜底
|
||||
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("dlbtn2").addEventListener("click", handleDownload);
|
||||
document.getElementById("dlbtn2").addEventListener("click", handleWebsiteDownload);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1,67 @@
|
||||
# 穿山甲 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 的人部署。
|
||||
@@ -0,0 +1,38 @@
|
||||
# 每天拉穿山甲 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
|
||||
@@ -0,0 +1,14 @@
|
||||
# 每天 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
|
||||
+10
-2
@@ -1,9 +1,9 @@
|
||||
# 傻瓜比价 App 后端 — API 接口文档(索引)
|
||||
|
||||
> Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770`
|
||||
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**
|
||||
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**(⚠️ 例外:消息通知中心 `notifications` 族与厂商推送 `push` 族按 PRD 前端契约用 **camelCase**,见各自文档)
|
||||
> 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer <access_token>`
|
||||
> 最后更新:2026-06-23(补全此前缺整族的端点:device 无障碍存活监控、internal 内部回写、CPS 短链落地、report/invite/wxpay,及 platform/coupon/feedback/user 的零散读端点;device/internal/cps-redirect 三族新建单文件文档。上一次 2026-06-18 补录比价透传端点与 `meituan/top-sales`)
|
||||
> 最后更新:2026-07-14(新增 **消息通知中心** 3 端点(M1-M3,虚拟数据阶段)与 **厂商推送测试** 3 端点(P1-P3,荣耀/华为/小米/OPPO/vivo);上一次 2026-06-23 补全 device/internal/CPS 短链等整族端点)
|
||||
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
|
||||
|
||||
---
|
||||
@@ -99,6 +99,14 @@
|
||||
| 38 | `POST /api/v1/feedback` | Bearer | [详情](./feedback.md) |
|
||||
| 38a | `GET /api/v1/feedback/config` | Bearer | 反馈页「加群二维码」卡配置(开关 + 二维码图 + 三行文案)(无单独文档) |
|
||||
| 38b | `GET /api/v1/feedback/records` | Bearer | 我的反馈历史(pending/adopted/rejected)(无单独文档) |
|
||||
| **消息通知中心**(前缀 `/api/v1/notifications`;⚠️ 本族对外 **camelCase**;虚拟数据阶段:内存 mock,重启复位) |||
|
||||
| M1 | `GET /api/v1/notifications` | Bearer | [详情](./notifications.md)(消息列表,分页;13 类型卡片字段 + sentAt/isRead;服务端已按时间倒序排好,不分组) |
|
||||
| M2 | `GET /api/v1/notifications/unread-count` | Bearer | [详情](./notifications.md)(未读总数,首页铃铛角标;>99 → "99+",0 → null 隐藏) |
|
||||
| M3 | `POST /api/v1/notifications/read` | Bearer | [详情](./notifications.md)(标记已读:`{ids:[...]}` 单条/多条 或 `{all:true}` 进通知中心全量清零;幂等) |
|
||||
| **厂商推送测试**(前缀 `/api/v1/push`;荣耀/华为/小米/OPPO/vivo 五通道联调三件套,同为 camelCase) |||
|
||||
| P1 | `GET /api/v1/push/vendors` | Bearer | [详情](./push-vendor-test.md)(5 厂商服务端凭据配置状态,缺哪些 .env 键一目了然) |
|
||||
| P2 | `GET /api/v1/push/templates` | Bearer | [详情](./push-vendor-test.md)(13 类通知的 push 标题/正文模板 + PRD 示例渲染效果) |
|
||||
| P3 | `POST /api/v1/push/test` | Bearer | [详情](./push-vendor-test.md)(测试发送:默认 mock 不真发;mock=false 真发;可联动插一条站内 mock 通知闭环验证已读) |
|
||||
| **首页门面数据 / 客户端配置**(前缀 `/api/v1/platform`;全平台展示数字 + 运营开关,**全部不鉴权**,登录前可读) |||
|
||||
| 39 | `GET /api/v1/platform/stats` | 无 | [详情](./platform-stats.md) |
|
||||
| 40 | `GET /api/v1/platform/savings-feed` | 无 | [详情](./platform-savings-feed.md) |
|
||||
|
||||
@@ -46,7 +46,10 @@
|
||||
| `total` | int | 当前筛选下的**分页总条数**(全量,不受分页影响;= 前端分页器 total) |
|
||||
| `truncated` | bool | 当前页之后是否还有更多事件(`len(events) > offset + limit`) |
|
||||
| `total_impressions` | int | 全量展示条数合计 |
|
||||
| `total_revenue_yuan` | float | 全量收益合计(元) |
|
||||
| `total_revenue_yuan` | float | 全量**客户端预估**收益合计(元;eCPM 折算) |
|
||||
| `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_actual_coin` | int | 全量实发金币合计 |
|
||||
| `mismatch_count` | int | 应发≠实发的组数(=0 说明全部按公式发放) |
|
||||
@@ -57,7 +60,9 @@
|
||||
|---|---|---|
|
||||
| `date` | string | 北京时间 `YYYY-MM-DD` |
|
||||
| `impressions` | int | 当天展示条数合计 |
|
||||
| `revenue_yuan` | float | 当天预估收益合计(元) |
|
||||
| `revenue_yuan` | float | 当天客户端预估收益合计(元;eCPM 折算) |
|
||||
| `pangle_revenue_yuan` | float \| null | 当天穿山甲后台预估收益(元;GroMore `revenue`);非全量视图 / 无数据为 `null` |
|
||||
| `pangle_api_revenue_yuan` | float \| null | 当天穿山甲收益Api(元;GroMore `api_revenue`);未配 / 当天 / 无数据为 `null` |
|
||||
| `expected_coin` | int | 当天应发金币合计 |
|
||||
| `actual_coin` | int | 当天实发金币合计 |
|
||||
|
||||
@@ -125,5 +130,6 @@
|
||||
- **展示 vs 发奖分离**:信息流轮播一会话可展示多条(都计入 `impressions`),但发奖仍按现规则(一会话发一次),`coin` 不因展示条数变化——这是有意设计(用户中途关只记展示不发奖)。
|
||||
- **历史 Draw 不可拆**:迁移(Draw→普通信息流)前,Draw 发奖混在 `ad_feed_reward_record` 且无类型标记,金币侧统一记 `feed`;迁移后 Draw 不再产生新数据。展示侧 `ad_type` 由客户端上报区分,故 `draw` 桶基本为空。
|
||||
- **来源字段从上线起齐全**:`app_env`/`our_code_id` 是本期新增列,历史记录为 NULL(报表来源列留空)。
|
||||
- **收益是预估**:基于客户端上报的 eCPM,非穿山甲后台结算值;以后台报表为结算权威。
|
||||
- **逐条/明细的收益是预估**:`items[].revenue_yuan` 基于客户端上报的 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) 仍保留(同一复算口径,可全局按场景/只看不符筛选)。
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# 消息通知中心(notifications 族)
|
||||
|
||||
> 所属:notifications 组(前缀 `/api/v1/notifications`,源 `app/api/v1/notifications.py`) | 鉴权:**全部 Bearer**(消息按用户隔离) | [← 返回 API 索引](./README.md)
|
||||
>
|
||||
> 对应 PRD《消息通知中心》(通知类型清单 / 点击跳转 / 字段元素 / 未读红点 / Push 文案)。
|
||||
> Push 侧(厂商直推 + 测试)见 [push-vendor-test.md](./push-vendor-test.md)。
|
||||
>
|
||||
> ⚠️ **虚拟数据阶段**:数据来自内存 mock(`app/repositories/notification_mock.py`),每个用户首次访问生成一套固定样例(13 种类型全覆盖 + 今天/昨天/当年/跨年四种时间 + 已读未读混合,共 16 条,id 从 90001 起);已读状态在服务端内存维护,**重启服务复位**。后续接真实数据只换 repository 实现,接口契约不变。
|
||||
>
|
||||
> ⚠️ **字段命名**:本组接口(含 push 测试组)对外为 **camelCase**(`sentAt` / `isRead` / `pageSize`…),与库内其他 snake_case 接口不同——按 PRD 前端契约对接,勿混用。
|
||||
|
||||
## 通知类型速查(13 种)
|
||||
|
||||
列表**服务端已排好序:全列表按时间倒序**(最新在前,**不做分类分组**——PRD §1 的"按分类分组"为笔误,2026-07-14 需求方确认取消),前端按返回顺序渲染即可。category 仅用于卡片头部的分类标签展示。
|
||||
|
||||
| category | 分类标签 | type | 类型 | cardStyle 版式 | actionText 操作行 | extra 里带什么 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| withdraw_assistant | 提现助手 | `reward_expiring` | 金币现金奖励即将失效 | dual_amount 双金额卡 | 立即激活您的收益 | `batchId` |
|
||||
| withdraw_assistant | 提现助手 | `reward_expired` | 金币现金奖励已失效 | dual_amount 双金额卡 | 立即赚取新收益 | — |
|
||||
| withdraw_assistant | 提现助手 | `withdraw_success` | 提现成功 | withdraw 提现卡 | **null(无操作行,点击仅消红点)** | — |
|
||||
| withdraw_assistant | 提现助手 | `withdraw_failed` | 提现失败,款项已退回 | withdraw 提现卡 | 重新提现 | `withdrawId` |
|
||||
| system | 系统通知 | `perm_accessibility` | 比价功能异常(无障碍) | plain_text 纯文本卡 | 去开启 | `permission:"accessibility"` |
|
||||
| system | 系统通知 | `perm_battery` | 比价续航保护异常(省电策略) | plain_text 纯文本卡 | 去开启 | `permission:"battery"` |
|
||||
| system | 系统通知 | `perm_autostart` | 比价启动保护异常(自启动) | plain_text 纯文本卡 | 去开启 | `permission:"autostart"` |
|
||||
| system | 系统通知 | `perm_overlay` | 比价按钮异常(悬浮窗) | plain_text 纯文本卡 | 去开启 | `permission:"overlay"` |
|
||||
| feedback | 我的反馈 | `feedback_reply` | 官方回复 | plain_text 纯文本卡 | 查看详情 | `feedbackId` |
|
||||
| feedback | 我的反馈 | `feedback_reward` | 反馈奖励(必带官方留言行) | coin_reward 金币奖励卡 | 查看反馈详情 | `feedbackId` |
|
||||
| report | 我的爆料 | `report_approved` | 爆料审核通过 | coin_reward 金币奖励卡 | 查看爆料详情 | `reportId` |
|
||||
| invite | 好友邀请 | `invite_order_reward` | 好友下单奖励到账 | friend_cash 好友现金卡 | 邀请更多好友赚现金 | `inviteeNickname` |
|
||||
| invite | 好友邀请 | `invite_remind` | 好友催单提醒 | plain_text 纯文本卡 | 去提醒 TA | `inviteeNickname`, `scrollTo:"remind"` |
|
||||
|
||||
点击跳转逻辑按 PRD §2 由客户端按 `type` 分发;点击目标 = 整张卡片(不区分主体和操作行),任何点击都先调 `POST /read` 消该条红点。
|
||||
|
||||
## GET /api/v1/notifications — 消息列表(分页)
|
||||
|
||||
**入参(query)**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `page` | int | ❌ | 页码,1 起,默认 1 |
|
||||
| `pageSize` | int | ❌ | 每页条数,默认 20,最大 100 |
|
||||
|
||||
**出参**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": 90001,
|
||||
"category": "withdraw_assistant", // 分类 key(5 种,见上表)
|
||||
"categoryLabel": "提现助手", // 卡片头部左上角分类标签
|
||||
"type": "reward_expiring", // 类型 key(13 种,决定点击行为)
|
||||
"cardStyle": "dual_amount", // 版式:dual_amount/withdraw/plain_text/coin_reward/friend_cash
|
||||
"title": "金币现金奖励即将失效", // 卡片标题
|
||||
"coins": 86, // 金币数,整数;无金币的版式为 null
|
||||
"cashCents": 1280, // 现金金额(分);无现金的版式为 null
|
||||
"cashYuan": "12.80", // 现金展示串(元,两位小数),与 cashCents 同源
|
||||
"infoRows": [ // 信息行,已按 PRD 拼好文案,逐行 label: value 渲染
|
||||
{ "label": "过期说明", "value": "您有86金币和12.80元现金即将失效,完成一次一键领券或一键比价即可激活收益" },
|
||||
{ "label": "过期时间", "value": "3天后失效" }
|
||||
],
|
||||
"actionText": "立即激活您的收益", // 操作行;null = 无操作行(提现成功卡)
|
||||
"extra": { "batchId": "batch_20260714" }, // 跳转/联动参数,按 type 取用(见上表)
|
||||
"sentAt": "2026-07-14T14:59:58+08:00", // ISO8601 带时区
|
||||
"isRead": false // false → 分类标签右侧显示 6px 红点(#E53935)
|
||||
}
|
||||
],
|
||||
"page": 1,
|
||||
"pageSize": 20,
|
||||
"total": 16,
|
||||
"hasMore": false,
|
||||
"unreadCount": 12 // 与 /unread-count 同口径,进页面可顺手刷角标
|
||||
}
|
||||
```
|
||||
|
||||
**时间显示规则(前端处理 `sentAt`)**:今天 →「今天」;昨天 →「昨天」;当年 →「M月D日」(不补零);跨年 →「YYYY年M月D日」。mock 样例特意覆盖了这四种情况。
|
||||
|
||||
**数值约束(PRD §3)**:金币整数不带小数;现金/提现金额两位小数(直接用 `cashYuan`)。
|
||||
|
||||
## GET /api/v1/notifications/unread-count — 未读总数(首页铃铛角标)
|
||||
|
||||
无入参。**出参**:
|
||||
|
||||
```jsonc
|
||||
{ "count": 12, "badgeText": "12" } // count>99 时 badgeText="99+";count=0 时 badgeText=null → 整个角标隐藏
|
||||
```
|
||||
|
||||
刷新时机(PRD §4):进入首页时、从通知中心/其他页面返回首页时(原型监听 `pageshow`)。
|
||||
|
||||
## POST /api/v1/notifications/read — 标记已读
|
||||
|
||||
**入参(JSON),两种模式二选一(同时传时 `all` 优先)**
|
||||
|
||||
| 模式 | body | 使用场景 |
|
||||
|---|---|---|
|
||||
| 单条/多条 | `{ "ids": [90001, 90003] }` | ① 点击某张消息卡片(点击后无论跳转/弹窗/无动作都算已读);② 用户点击 push 直达落地页后,客户端拿 push extras 里的 `notificationId` 同步置读 |
|
||||
| 全量清零 | `{ "all": true }` | 进入通知中心自动清零(只浏览列表就消红点,无需逐条点击;退出通知中心时也可再调一次兜底) |
|
||||
|
||||
**出参**
|
||||
|
||||
```jsonc
|
||||
{ "ok": true, "markedCount": 2, "unreadCount": 10 } // unreadCount = 处理后剩余未读,可直接刷新角标
|
||||
```
|
||||
|
||||
幂等:不存在/已读的 id 忽略,重复调用 `markedCount=0` 不报错。
|
||||
|
||||
**错误**:`400` ids 与 all 都没传(或 ids 为空数组);`401` 未鉴权。
|
||||
|
||||
## 联调小抄
|
||||
|
||||
```bash
|
||||
# 1. 登录拿 token(SMS mock:任意手机号 + 任意 6 位验证码)
|
||||
curl -X POST :8770/api/v1/auth/sms/send -d '{"phone":"13800001234"}'
|
||||
curl -X POST :8770/api/v1/auth/sms/login -d '{"phone":"13800001234","code":"123456"}'
|
||||
# 2. 列表 / 角标 / 置读
|
||||
curl ":8770/api/v1/notifications?page=1&pageSize=20" -H "Authorization: Bearer $TOKEN"
|
||||
curl ":8770/api/v1/notifications/unread-count" -H "Authorization: Bearer $TOKEN"
|
||||
curl -X POST ":8770/api/v1/notifications/read" -d '{"all":true}' -H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
要重置 mock 数据(重新生成整套样例):重启服务即可。想造「push → 站内联动」数据,用 [POST /api/v1/push/test](./push-vendor-test.md) 的 `createNotification:true`。
|
||||
@@ -0,0 +1,102 @@
|
||||
# 厂商推送测试三件套(push 族)
|
||||
|
||||
> 所属:push 组(前缀 `/api/v1/push`,源 `app/api/v1/push.py`) | 鉴权:**全部 Bearer** | [← 返回 API 索引](./README.md)
|
||||
>
|
||||
> 发送实现:`app/integrations/vendor_push.py`(荣耀 / **华为** / 小米 / OPPO / vivo 五通道,
|
||||
> `send_notification()` 通用入口)。站内消息中心见 [notifications.md](./notifications.md)。
|
||||
> 与 `POST /api/v1/device/push-test`(无障碍召回通道延迟自测)互补:本组面向消息中心 13 类 push 的文案/参数/通道联调。
|
||||
>
|
||||
> 字段命名同 notifications 族:**camelCase**。
|
||||
|
||||
## 链路总览
|
||||
|
||||
```
|
||||
真实业务事件(奖励过期/提现回执/审核通过/…)
|
||||
└→ vendor_push.send_notification(vendor, token, title, body, extras)
|
||||
extras = { type, notificationId, ...业务参数 } ← 客户端深链 + 已读联动的钥匙
|
||||
客户端点击 push → 按 extras.type 直达落地页(与站内点击一致)
|
||||
→ 调 POST /notifications/read {ids:[extras.notificationId]} 同步消红点(PRD §4)
|
||||
```
|
||||
|
||||
推送目标来源:客户端集成各厂商 push SDK 拿到 regId/token 后,通过 `POST /api/v1/device/register` 上报 `push_vendor` + `push_token`,服务端存 `device_liveness` 表。
|
||||
|
||||
## GET /api/v1/push/vendors — 厂商配置状态
|
||||
|
||||
检查 5 家厂商服务端凭据是否配齐(只读 .env,不打厂商接口)。`missingKeys` 即还要补的配置键;mock 测试不依赖任何凭据。
|
||||
|
||||
```jsonc
|
||||
{ "vendors": [
|
||||
{ "vendor": "honor", "label": "荣耀", "configured": false, "missingKeys": ["HONOR_PUSH_APP_ID", "HONOR_PUSH_CLIENT_ID", "HONOR_PUSH_CLIENT_SECRET"] },
|
||||
{ "vendor": "huawei", "label": "华为", "configured": false, "missingKeys": ["HUAWEI_PUSH_APP_ID", "HUAWEI_PUSH_APP_SECRET"] },
|
||||
{ "vendor": "xiaomi", "label": "小米", "configured": true, "missingKeys": [] },
|
||||
{ "vendor": "oppo", "label": "OPPO", "configured": false, "missingKeys": ["OPPO_PUSH_APP_KEY", "OPPO_PUSH_MASTER_SECRET"] },
|
||||
{ "vendor": "vivo", "label": "vivo", "configured": false, "missingKeys": ["VIVO_PUSH_APP_ID", "VIVO_PUSH_APP_KEY", "VIVO_PUSH_APP_SECRET"] }
|
||||
] }
|
||||
```
|
||||
|
||||
## GET /api/v1/push/templates — 13 类通知的 push 模板预览
|
||||
|
||||
PRD §5 的 13 条 push 文案(标题固定 ≤11 字不带变量;正文 `{var}` 为变量,示例值即 PRD 示例)。对文案、看变量名用。
|
||||
|
||||
```jsonc
|
||||
{ "templates": [
|
||||
{
|
||||
"type": "withdraw_success",
|
||||
"category": "withdraw_assistant", "categoryLabel": "提现助手", "cardStyle": "withdraw",
|
||||
"pushTitle": "提现到账提醒",
|
||||
"pushBodySample": "¥0.50已存入您的微信钱包,点击查看到账详情", // 用示例值渲染后的效果
|
||||
"pushBodyTemplate": "¥{amount}已存入您的微信钱包,点击查看到账详情",
|
||||
"variables": ["amount"],
|
||||
"sampleVars": { "amount": "0.50" }
|
||||
}
|
||||
// ... 共 13 条,顺序即 PRD 编号
|
||||
] }
|
||||
```
|
||||
|
||||
## POST /api/v1/push/test — 测试发送(默认 mock)
|
||||
|
||||
**入参(JSON)**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `vendor` | string | ❌* | `honor/huawei/xiaomi/oppo/vivo`,中文「华为」「小米」等别名也识别;留空时用 `deviceId` 设备上报的 vendor |
|
||||
| `pushToken` | string | ❌* | 厂商 push token/regId;留空则按 `deviceId` 反查已注册设备(*mock 模式两者都缺时用占位 token,只看渲染结果*) |
|
||||
| `deviceId` | string | ❌ | 客户端 `DeviceId.get()` 的设备 id,用于反查 vendor+token |
|
||||
| `type` | string | ❌ | 13 种类型 key 之一 → 按 PRD 模板渲染;不传且没直给文案 → 发通用测试文案 |
|
||||
| `vars` | object | ❌ | 覆盖模板变量,如 `{"coins":"520","cash":"6.66"}`;缺省用 PRD 示例值 |
|
||||
| `title` / `content` | string | ❌ | 直接指定标题/正文(优先于 type 模板) |
|
||||
| `createNotification` | bool | ❌ | true = 同时往该用户消息中心插一条同类型未读 mock 通知,extras 带其 `notificationId` → 可闭环验证「点 push → 调 /notifications/read 消红点」(仅 type 合法时生效) |
|
||||
| `mock` | bool | ❌ | **默认 true = 不真调厂商 API**,回显渲染结果;false = 真发到手机(要求该厂商凭据已配) |
|
||||
|
||||
**出参**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"ok": true, "mock": true, "vendor": "huawei",
|
||||
"title": "反馈奖励已到账",
|
||||
"body": "谢谢您帮傻瓜比价变得更好,300金币已到账,还有一条给您的留言~",
|
||||
"extras": { "type": "feedback_reward", "feedbackId": "3002", "notificationId": "90017" },
|
||||
"notificationId": 90017, // createNotification=true 时的站内 mock 通知 id
|
||||
"missingKeys": ["HUAWEI_PUSH_APP_ID", "HUAWEI_PUSH_APP_SECRET"], // 真发前还缺的配置(真发成功时必为空)
|
||||
"vendorResponse": null // 真发时为厂商 API 原始响应
|
||||
}
|
||||
```
|
||||
|
||||
**错误**:`400` vendor/type 非法、真发但凭据未配(detail 列缺失键);`409` 真发但拿不到 pushToken;`502` 厂商 API 返回失败(detail 带厂商原始错误)。
|
||||
|
||||
**真发注意**:
|
||||
- 目标手机必须先装 App 且客户端已集成对应厂商 SDK、`/device/register` 上报过 token;
|
||||
- vivo 未上架前走测试推送(`VIVO_PUSH_MODE=1`),目标手机需在 vivo 开放平台加入测试设备;
|
||||
- 小米新设备需在开放平台把签名/包名配好,token 才有效。
|
||||
|
||||
## 厂商凭据怎么拿(.env 键名)
|
||||
|
||||
| 厂商 | 后台 | 需要的键 |
|
||||
|---|---|---|
|
||||
| 华为 | AGC 控制台 → 项目设置 → 常规 → 应用 | `HUAWEI_PUSH_APP_ID`、`HUAWEI_PUSH_APP_SECRET`(OAuth client_id 即 AppId) |
|
||||
| 荣耀 | 荣耀开发者服务平台 → 推送服务 | `HONOR_PUSH_APP_ID`、`HONOR_PUSH_CLIENT_ID`、`HONOR_PUSH_CLIENT_SECRET` |
|
||||
| 小米 | 开放平台 → 消息推送 → 应用秘钥 | `XIAOMI_PUSH_APP_SECRET`(服务端只要这个;AppID/AppKey 是客户端 SDK 用) |
|
||||
| OPPO | 开放平台 → 推送服务 | `OPPO_PUSH_APP_KEY`、`OPPO_PUSH_MASTER_SECRET`(注意是**服务端 MasterSecret**) |
|
||||
| vivo | 开放平台 → 推送 | `VIVO_PUSH_APP_ID`、`VIVO_PUSH_APP_KEY`、`VIVO_PUSH_APP_SECRET` |
|
||||
|
||||
各家发送协议差异(鉴权方式/成功码/payload 结构)封装在 `integrations/vendor_push.py`,业务侧只面对 `send_notification()`。
|
||||
@@ -0,0 +1,45 @@
|
||||
# ad_pangle_daily_revenue — 穿山甲 GroMore 天级收益
|
||||
|
||||
> 模型:[app/models/ad_pangle_revenue.py](../../app/models/ad_pangle_revenue.py) | 读写:[app/repositories/ad_pangle_revenue.py](../../app/repositories/ad_pangle_revenue.py) | 拉取:[app/integrations/pangle_report.py](../../app/integrations/pangle_report.py) + [scripts/sync_pangle_revenue.py](../../scripts/sync_pangle_revenue.py)
|
||||
|
||||
存放从穿山甲 **GroMore 数据 API**(聚合数据报告 API,天级、T+1)按天拉取的收益,供 admin
|
||||
[广告收益报表](../api/admin-ad-revenue-report.md) 的「穿山甲后台收益」做汇总/趋势级展示,
|
||||
与客户端自报 eCPM 折算的预估互为对照(看 gap)。
|
||||
|
||||
**粒度 = 日期 × 应用(app_env) × 代码位(our_code_id) × 广告源(adn)**。穿山甲**不提供分用户/设备维度**
|
||||
(官方明确),故本表无 `user_id`,也无法挂到逐条广告事件;报表逐条行仍用客户端预估。
|
||||
|
||||
## 数据流
|
||||
|
||||
- **写**:`scripts/sync_pangle_revenue`(线上每天 ~10:30 由 systemd timer 跑)调
|
||||
`pangle_report.fetch_daily_report` 拉昨天(可 `--days N` 回补),维度 `date,site_id,ad_unit_id`,
|
||||
映射 `site_id→app_env`、`ad_unit_id→our_code_id` 后 `upsert_daily_rows` 落库。按唯一键幂等,T+1 订正可重跑。
|
||||
⚠️ 用 `ad_unit_id`(广告位ID=104xxx)而非 `code_id`:实测 `code_id` 返回底层各 ADN 代码位(如 `983674557`),对不上我们的口径。
|
||||
- **读**:`ad_pangle_revenue.aggregate_by_date` 按日期汇总 → admin 报表
|
||||
`total_pangle_revenue_yuan` / `total_pangle_api_revenue_yuan` / `daily[].pangle_*`。
|
||||
|
||||
## 字段
|
||||
|
||||
| 列 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | int PK | |
|
||||
| `report_date` | str(10) idx | 北京时间 `YYYY-MM-DD`(拉取 `time_zone=8`),与 `ad_ecpm_record.report_date` 同口径可 join |
|
||||
| `app_env` | str(16) | `prod`(傻瓜比价正式)/ `test`(测试);由 `site_id` 经 `settings.pangle_report_site_id_to_env` 映射;未映射记 `site:<id>` |
|
||||
| `site_id` | str(32) \| null | 原始 GroMore AppId(留痕) |
|
||||
| `our_code_id` | str(64) idx | 广告位 ID(= 接口 `ad_unit_id` = 客户端配的 104xxx = `ad_ecpm_record.our_code_id`),join key。⚠️ **非** `code_id`(那是底层各 ADN 代码位,对不上);`-1` 为 GroMore 未归因桶 |
|
||||
| `adn` | str(16) | 广告源(接口 `network`);当前默认口径不分广告源(按 `ad_unit_id` 汇总),统一 `""` |
|
||||
| `revenue_yuan` | float | 预估收益(元)← 接口 `revenue`(排序价×展示/1000,所有 ADN) |
|
||||
| `api_revenue_yuan` | float \| null | 收益Api(元)← 接口 `api_revenue`(各 ADN 经 Reporting 回传、更接近结算);未配 / 当天为 `null` |
|
||||
| `ecpm` | str(32) \| null | 预估 eCPM 原值(接口 `ecpm`,单位**元/千次**,与客户端 getEcpm 的「分」不同) |
|
||||
| `impressions` | int | 展示次数 ← 接口 `imp_cnt` |
|
||||
| `currency` | str(8) | 货币(接口 `currency`,正常 `cny`) |
|
||||
| `synced_at` | datetime | 最近同步写入时间(可被回补覆盖) |
|
||||
|
||||
唯一约束 `uq_ad_pangle_daily (report_date, app_env, our_code_id, adn)`;`adn` 用 `""` 而非
|
||||
NULL,避免 NULL 在唯一约束里互不相同导致 upsert 重复。
|
||||
|
||||
## 局限
|
||||
|
||||
- **T+1**:当天数据穿山甲不出 `api_revenue`,`revenue` 也次日才稳;报表「今天」此两列多为空。
|
||||
- **`revenue` vs `api_revenue`**:前者所有 ADN 都有(预估口径);后者需后台为该 ADN 配置 Reporting
|
||||
才回传,部分 ADN 可能长期为空。结算仍以各 ADN 结算单 / 穿山甲后台为准。
|
||||
@@ -37,7 +37,7 @@ B 安装并首启 App
|
||||
└─ POST /api/v1/invite/bind { invite_code, channel="clipboard" }
|
||||
↓
|
||||
后端 repositories/invite.py bind()
|
||||
└─ 过四道防线 → 建 invite_relation + 给 A、B 各发金币(同事务原子提交)
|
||||
└─ 过四道防线 → 建 invite_relation(邀请金币已下线,不写金币流水)
|
||||
```
|
||||
|
||||
手动填码这条:B 在邀请页输码 → `InviteRepository.bindManual()` → `POST /bind { channel="manual" }` → 同一个 `bind()`。
|
||||
@@ -52,20 +52,20 @@ B 安装并首启 App
|
||||
|---|---|
|
||||
| 端点 | `app/api/v1/invite.py`:`GET /api/v1/invite/me`(返回 `invite_code` + `share_url` + 战绩)、`POST /api/v1/invite/bind`(绑定,`channel` = `clipboard` / `manual`)。**均需 Bearer 鉴权**。 |
|
||||
| share_url 构造 | `invite.py` 的 `my_invite`:`settings.INVITE_LANDING_URL + "?ref=" + code`。`INVITE_LANDING_URL` 在 `app/core/config.py`(默认 `https://app-api.shaguabijia.com/media/dl.html`)。 |
|
||||
| 业务逻辑 | `app/repositories/invite.py`:`ensure_code`(懒生成 6 位邀请码,去混淆字符集,唯一约束碰撞则换码)/ `resolve_inviter`(邀请码→邀请人,大小写不敏感)/ `bind`(下面详述)/ `get_stats`(已邀人数 + 累计金币)。 |
|
||||
| 业务逻辑 | `app/repositories/invite.py`:`ensure_code`(懒生成 6 位邀请码,去混淆字符集,唯一约束碰撞则换码)/ `resolve_inviter`(邀请码→邀请人,大小写不敏感)/ `bind`(下面详述)/ `get_stats`(已邀人数 + 兼容累计金币字段,当前恒为 0)。 |
|
||||
| 数据模型 | `app/models/invite.py` 的 `InviteRelation`(`inviter_user_id` / `invitee_user_id` / `channel` / `status` / `inviter_coin` / `invitee_coin` / `created_at`)+ `app/models/user.py` 的 `User.invite_code` 列。 |
|
||||
| 迁移 | `alembic/versions/invite_code_and_relation.py`:给 `user` 加 `invite_code`(唯一索引)+ 建 `invite_relation` 表。`down_revision = 11a1d08c6f55`。 |
|
||||
| 收发模型 | `app/schemas/invite.py`:`InviteInfoOut` / `BindInviteIn` / `BindInviteOut`。 |
|
||||
| 奖励常量 | `app/core/rewards.py`:`INVITE_INVITER_COINS` / `INVITE_INVITEE_COINS`(各 10000 = 1 元)、`INVITE_NEW_USER_WINDOW_HOURS`(72)。 |
|
||||
| 新人窗口 | `app/core/rewards.py`:`INVITE_NEW_USER_WINDOW_HOURS`(72)。邀请金币已下线,不再配置邀请金币常量。 |
|
||||
|
||||
**`bind()` 的四道防线(防重复 / 防刷,看 `repositories/invite.py`):**
|
||||
|
||||
1. **被邀请人唯一**:`invitee_user_id` 唯一约束 → 一个 B 只能被绑一次(幂等键,重复返回 `already_bound`,不重复发奖)。
|
||||
1. **被邀请人唯一**:`invitee_user_id` 唯一约束 → 一个 B 只能被绑一次(幂等键,重复返回 `already_bound`,不重复绑定)。
|
||||
2. **自邀屏蔽**:`inviter == invitee` → `self_invite`。
|
||||
3. **新人闸**:`_is_new_user`(B 的 `created_at` 在 `INVITE_NEW_USER_WINDOW_HOURS` = 72h 内)才发奖,挡存量老用户互相填码薅羊毛 → 否则 `not_eligible`。
|
||||
3. **新人闸**:`_is_new_user`(B 的 `created_at` 在 `INVITE_NEW_USER_WINDOW_HOURS` = 72h 内)才生效,挡存量老用户互相填码刷关系 → 否则 `not_eligible`。
|
||||
4. **手机号唯一**(天然限量):每个 B = 一个真实手机号账号。
|
||||
|
||||
发金币复用 `repositories/wallet.py` 的 `grant_coins`,与建关系记录在**同一事务**提交,保证"建关系 + 双方加金币"原子。
|
||||
邀请金币已下线:`bind()` 只记录绑定关系,不再写 `coin_transaction`;响应里的金币字段保留兼容旧客户端,当前恒为 0。
|
||||
|
||||
### 3.2 前端(shaguabijia-app-android)
|
||||
|
||||
@@ -120,7 +120,7 @@ B 安装并首启 App
|
||||
### 4.4 测试硬约束 / 坑(都是机制,不是 bug)
|
||||
|
||||
- **B 必须用新手机号**:`invitee_user_id` 唯一,一个 B 只能绑一次;反复测要换号(或手删 `invite_relation` 那行 + 回滚金币)。
|
||||
- **72h 新人闸**:B 注册后 72 小时内绑才发奖(刚注册肯定满足)。
|
||||
- **72h 新人闸**:B 注册后 72 小时内绑定才生效(刚注册肯定满足)。
|
||||
- **A ≠ B**:自邀被屏蔽。
|
||||
- **B 从点下载到首启 App 之间别复制别的东西**:剪贴板会被覆盖 → 归因丢(剪贴板 deferred deeplink 的固有脆弱性)。
|
||||
- **笔记本 IP 别变**:debug 包把 `BASE_URL` 的 IP 烧死在编译期,DHCP 一换就连不上 → 给笔记本固定个 LAN IP。
|
||||
|
||||
+7771
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* SGApi —— H5 调 app-server 后端的薄封装。
|
||||
*
|
||||
* 同源:H5 由 app-server 的 /media/h5/ 托管,后端在 /api/v1,同 host:port → 用相对路径、无 CORS。
|
||||
* 鉴权:JWT Bearer。token 经 SGBridge.getToken() 从原生取(原生持登录态);浏览器调试走 bridge 的 mock token。
|
||||
* 401:交原生拉登录(requestLogin)兜底,本次请求按失败 reject;正式的 refresh 重试策略阶段2 再补。
|
||||
*
|
||||
* 依赖 shared/bridge.js 先加载(取 token)。
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var BASE = '/api/v1';
|
||||
|
||||
function authHeaders() {
|
||||
var t = (global.SGBridge && global.SGBridge.getToken()) || '';
|
||||
var h = { 'Content-Type': 'application/json' };
|
||||
if (t) h['Authorization'] = 'Bearer ' + t;
|
||||
return h;
|
||||
}
|
||||
|
||||
function handle(res) {
|
||||
if (res.status === 401) {
|
||||
// 未授权:拉原生登录(异步),本次请求按失败处理,调用方自行决定是否重试
|
||||
if (global.SGBridge) global.SGBridge.requestLogin();
|
||||
return Promise.reject(new Error('unauthorized'));
|
||||
}
|
||||
if (!res.ok) {
|
||||
return res.text().then(function (t) {
|
||||
return Promise.reject(new Error('http ' + res.status + ' ' + t));
|
||||
});
|
||||
}
|
||||
// 204 / 空体兜底
|
||||
return res.text().then(function (t) { return t ? JSON.parse(t) : null; });
|
||||
}
|
||||
|
||||
/** GET /api/v1<path>。path 以 / 开头,如 '/savings/battle'。 */
|
||||
function apiGet(path) {
|
||||
return fetch(BASE + path, { method: 'GET', headers: authHeaders() }).then(handle);
|
||||
}
|
||||
|
||||
/** POST /api/v1<path>,body 自动 JSON 序列化。 */
|
||||
function apiPost(path, body) {
|
||||
return fetch(BASE + path, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify(body || {}),
|
||||
}).then(handle);
|
||||
}
|
||||
|
||||
global.SGApi = { base: BASE, get: apiGet, post: apiPost };
|
||||
})(window);
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* SGBridge —— 傻瓜比价 H5 ↔ Android 原生 的桥。
|
||||
*
|
||||
* 背景:四个主 tab(首页/福利/记录/我的)由原生 Compose 改造为 WebView 加载本工程 H5。
|
||||
* H5 只负责"画 + 取后端数据";凡需要原生能力(登录态 / 跳转 / 跳外卖 App / 比价领券 /
|
||||
* 权限 / 定位 / Toast / 激励视频),一律经本桥调用原生。
|
||||
*
|
||||
* 协议两个方向:
|
||||
* ① H5 → 原生:Android 端 WebView.addJavascriptInterface(obj, "SGBridgeNative")。
|
||||
* obj 方法都是【同步】:查询类返回 String(JSON 或纯串),动作类无返回。
|
||||
* ② 原生 → H5:原生执行 evaluateJavascript("window.SGBridge._emit('<event>', '<json>')")。
|
||||
* 事件:onAuthChange(登录态变) / onBalanceChange(余额变) / onSigninChange(签到态变) / onResume(回前台刷新)。
|
||||
*
|
||||
* 离线兜底:浏览器里(无 SGBridgeNative)走 MOCK,便于不装 App 直接在本地 server 调样式 / 渲染。
|
||||
* 与原生实现对应:见 shaguabijia-app-android 的 SGBridge.kt(方法名逐个对齐本文件)。
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var native = global.SGBridgeNative || null;
|
||||
var hasNative = !!native;
|
||||
|
||||
// ---- 离线 MOCK(仅无原生时生效,便于浏览器调试渲染;真机一律走 native) ----
|
||||
var MOCK = {
|
||||
authState: { loggedIn: true, userId: 1, nickname: '冰', avatarUrl: '', phone: '188****8888' },
|
||||
token: 'mock-token-for-browser-debug',
|
||||
deviceId: 'browser-debug-device',
|
||||
appVersion: '0.0.0-debug',
|
||||
};
|
||||
|
||||
function safeParse(s, fallback) {
|
||||
try { return s ? JSON.parse(s) : fallback; } catch (e) { return fallback; }
|
||||
}
|
||||
|
||||
// ====== 查询类(同步返回) ======
|
||||
|
||||
/** 当前登录态 + 用户基本信息 → {loggedIn, userId, nickname, avatarUrl, phone}。 */
|
||||
function getAuthState() {
|
||||
if (hasNative && native.getAuthState) return safeParse(native.getAuthState(), { loggedIn: false });
|
||||
return MOCK.authState;
|
||||
}
|
||||
|
||||
/** 后端鉴权用的 access token(空串=未登录)。原生持登录态,H5 调后端前取它拼 Bearer。 */
|
||||
function getToken() {
|
||||
if (hasNative && native.getToken) return native.getToken() || '';
|
||||
return MOCK.token;
|
||||
}
|
||||
|
||||
/** 设备唯一标识(心跳 / 领券状态查询等用)。 */
|
||||
function getDeviceId() {
|
||||
if (hasNative && native.getDeviceId) return native.getDeviceId() || '';
|
||||
return MOCK.deviceId;
|
||||
}
|
||||
|
||||
/** App 版本号。 */
|
||||
function getAppVersion() {
|
||||
if (hasNative && native.getAppVersion) return native.getAppVersion() || '';
|
||||
return MOCK.appVersion;
|
||||
}
|
||||
|
||||
/** 已安装的目标电商/外卖 App 包名数组(原生 InstalledApps 探测)。H5 选平台弹窗据此判真实装机态。 */
|
||||
function getInstalledApps() {
|
||||
if (hasNative && native.getInstalledApps) return safeParse(native.getInstalledApps(), []);
|
||||
// MOCK(浏览器无原生):给主流已装,便于本地预览选平台弹窗正常显示"有"。
|
||||
return ['com.sankuai.meituan', 'com.taobao.taobao', 'com.jingdong.app.mall', 'me.ele'];
|
||||
}
|
||||
|
||||
/** 今日是否已领券(置灰「去领取」→「去查看」)。原生读 CompareButtonState(SP 按天);无桥默认 false。 */
|
||||
function getCouponClaimedToday() {
|
||||
if (hasNative && native.getCouponClaimedToday) return !!native.getCouponClaimedToday();
|
||||
return false;
|
||||
}
|
||||
|
||||
// ====== 动作类(无返回;异步结果走事件) ======
|
||||
|
||||
/** 跳原生页。route 取值对齐安卓 Routes(invite / settings / feedback / withdrawal / compareRecords / reportFlow / guideVideo / compareResult / coinHistory / cashHistory / welfareRules ...)。 */
|
||||
function navigate(route) {
|
||||
if (hasNative && native.navigate) native.navigate(route);
|
||||
else console.log('[SGBridge mock] navigate →', route);
|
||||
}
|
||||
|
||||
/** 拉起极光一键登录。结果异步经 onAuthChange 事件回来(不在此函数返回)。 */
|
||||
function requestLogin() {
|
||||
if (hasNative && native.requestLogin) native.requestLogin();
|
||||
else console.log('[SGBridge mock] requestLogin');
|
||||
}
|
||||
|
||||
/** 原生居中 Toast。 */
|
||||
function toast(msg) {
|
||||
if (hasNative && native.toast) native.toast(String(msg));
|
||||
else console.log('[SGBridge mock] toast →', msg);
|
||||
}
|
||||
|
||||
/** 跳美团/外卖 App(deeplink 优先;空则原生按包名启动,未装可跳应用商店)。 */
|
||||
function openMeituan(deeplink) {
|
||||
if (hasNative && native.openMeituan) native.openMeituan(deeplink || '');
|
||||
else console.log('[SGBridge mock] openMeituan →', deeplink);
|
||||
}
|
||||
|
||||
/** 触发 agent 比价流程(原生起无障碍引擎)。 */
|
||||
function startCompare() {
|
||||
if (hasNative && native.startCompare) native.startCompare();
|
||||
else console.log('[SGBridge mock] startCompare');
|
||||
}
|
||||
|
||||
/** 触发一键领券(原生先校验悬浮窗/无障碍权限,再起前台服务)。platforms: string[]。 */
|
||||
function startCouponClaim(platforms) {
|
||||
var json = JSON.stringify(platforms || []);
|
||||
if (hasNative && native.startCouponClaim) native.startCouponClaim(json);
|
||||
else console.log('[SGBridge mock] startCouponClaim →', json);
|
||||
}
|
||||
|
||||
/** 按候选包名拉起对应平台 App。照原生 HomePicker 点击跳转:原生在该平台一组候选包里取首个可
|
||||
* getLaunchIntentForPackage 的拉起(NEW_TASK|CLEAR_TASK 冷启到平台首页)。packages: string[](一个平台一组候选包,任一可拉即拉)。 */
|
||||
function launchApp(packages) {
|
||||
var json = JSON.stringify(packages || []);
|
||||
if (hasNative && native.launchApp) native.launchApp(json);
|
||||
else console.log('[SGBridge mock] launchApp →', json);
|
||||
}
|
||||
|
||||
// ====== 原生 → H5 事件总线 ======
|
||||
|
||||
var listeners = {}; // event → [fn]
|
||||
|
||||
/** 订阅原生事件。返回取消订阅函数。 */
|
||||
function on(event, fn) {
|
||||
(listeners[event] || (listeners[event] = [])).push(fn);
|
||||
return function off() {
|
||||
listeners[event] = (listeners[event] || []).filter(function (f) { return f !== fn; });
|
||||
};
|
||||
}
|
||||
|
||||
/** 供原生回调:window.SGBridge._emit('onAuthChange', '{...}')。payload 可为 JSON 串或对象。 */
|
||||
function _emit(event, payload) {
|
||||
var data = typeof payload === 'string' ? safeParse(payload, payload) : payload;
|
||||
(listeners[event] || []).forEach(function (fn) {
|
||||
try { fn(data); } catch (e) { console.error('[SGBridge] listener error', event, e); }
|
||||
});
|
||||
}
|
||||
|
||||
global.SGBridge = {
|
||||
hasNative: hasNative,
|
||||
// 查询
|
||||
getAuthState: getAuthState,
|
||||
getToken: getToken,
|
||||
getDeviceId: getDeviceId,
|
||||
getAppVersion: getAppVersion,
|
||||
getInstalledApps: getInstalledApps,
|
||||
getCouponClaimedToday: getCouponClaimedToday,
|
||||
// 动作
|
||||
navigate: navigate,
|
||||
requestLogin: requestLogin,
|
||||
toast: toast,
|
||||
openMeituan: openMeituan,
|
||||
startCompare: startCompare,
|
||||
startCouponClaim: startCouponClaim,
|
||||
launchApp: launchApp,
|
||||
// 事件
|
||||
on: on,
|
||||
_emit: _emit,
|
||||
};
|
||||
})(window);
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
# 编 release 正式包并发布到官网下载位 data/media/shaguabijia.apk。
|
||||
#
|
||||
# 背景:落地页 dl.html「官网下载」按钮指向 /media/shaguabijia.apk(见 app/main.py download_apk 路由)。
|
||||
# 该 apk 不入 git(.gitignore 忽略 *.apk),也没有 CI 自动放 —— 以前全靠手动 cp,常忘/放成 debug 包。
|
||||
# 本脚本把"编 release + 放到位"固化成一条命令。发版时跑一次即可。
|
||||
#
|
||||
# 用法: bash scripts/publish_apk.sh
|
||||
# 产物: <app-server>/data/media/shaguabijia.apk(release 签名、指向生产后端的正式包)
|
||||
#
|
||||
# 注意:① 需要 app/jishisongfu-release.jks 存在(release 签名,仅 CTO 持有);缺失会编译失败。
|
||||
# ② 服务器部署不在本脚本职责内 —— 它只把 apk 放到本仓 data/media/;上线由部署流程把
|
||||
# data/media/ 同步到生产(或 nginx serve 同目录)。
|
||||
set -euo pipefail
|
||||
|
||||
# 路径全部相对脚本位置算,跟 CWD 无关。
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SERVER_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
ANDROID_DIR="$(cd "$SERVER_DIR/../shaguabijia-app-android" && pwd)"
|
||||
DEST="$SERVER_DIR/data/media/shaguabijia.apk"
|
||||
APK_OUT="$ANDROID_DIR/app/build/outputs/apk/release/app-release.apk"
|
||||
|
||||
# Android Studio 自带 JBR(本机构建环境约定)。已设 JAVA_HOME 则尊重现有值。
|
||||
export JAVA_HOME="${JAVA_HOME:-D:/android-studio/jbr}"
|
||||
|
||||
echo "[publish_apk] Android 工程: $ANDROID_DIR"
|
||||
if [ ! -f "$ANDROID_DIR/app/jishisongfu-release.jks" ]; then
|
||||
echo "[publish_apk] ✗ 缺 app/jishisongfu-release.jks(release 签名),无法出正式包。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[publish_apk] 编 release 包(assembleRelease,R8+release 签名,BASE_URL=生产)…"
|
||||
( cd "$ANDROID_DIR" && ./gradlew :app:assembleRelease )
|
||||
|
||||
if [ ! -f "$APK_OUT" ]; then
|
||||
echo "[publish_apk] ✗ 没找到产物 $APK_OUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$DEST")"
|
||||
cp "$APK_OUT" "$DEST"
|
||||
SIZE_MB=$(( $(wc -c < "$DEST") / 1024 / 1024 ))
|
||||
echo "[publish_apk] ✓ 已发布 → $DEST (${SIZE_MB}MB)"
|
||||
echo "[publish_apk] 官网下载链接(生产):https://app-api.shaguabijia.com/media/shaguabijia.apk"
|
||||
echo "[publish_apk] 部署:把 data/media/ 同步到生产服务器(本脚本不负责上线)。"
|
||||
@@ -0,0 +1,151 @@
|
||||
"""每日拉取穿山甲 GroMore 天级收益报表入库(供 admin 广告收益报表的「穿山甲后台收益」)。
|
||||
|
||||
GroMore 数据 API 为 T+1:次日穿山甲约 10:00 出数。建议线上每天 ~10:30 由 systemd timer 跑一次
|
||||
(默认拉【昨天】);穿山甲对历史数据可能订正,故支持回补近 N 天(幂等 upsert,重跑无害)。
|
||||
|
||||
用法:
|
||||
python -m scripts.sync_pangle_revenue # 拉昨天(北京时间)
|
||||
python -m scripts.sync_pangle_revenue --days 3 # 回补昨天起往前 3 天
|
||||
python -m scripts.sync_pangle_revenue --date 2026-06-27 # 指定单天
|
||||
python -m scripts.sync_pangle_revenue --start 2026-06-01 --end 2026-06-27 # 指定区间
|
||||
|
||||
约束(来自官方文档):天级跨度 ≤ 1 个月、不早于 12 个月;「今天」与「今天以前」必须分开查,
|
||||
本脚本默认只拉昨天及更早,不混查今天。未配置 PANGLE_REPORT_* 时直接 no-op 退出。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from datetime import date, timedelta
|
||||
|
||||
# Windows 控制台按 UTF-8 输出中文/¥
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.rewards import cn_today
|
||||
from app.db.session import SessionLocal
|
||||
from app.integrations import pangle_report
|
||||
from app.integrations.pangle_report import PangleReportError
|
||||
from app.repositories import ad_pangle_revenue as repo
|
||||
|
||||
# 拉取维度固定:date×site_id×ad_unit_id。⚠️ 用 ad_unit_id(GroMore 广告位ID = 我们客户端配的 104xxx
|
||||
# = ad_ecpm_record.our_code_id)而**非** code_id —— 实测 code_id 返回的是底层各 ADN 的代码位
|
||||
# (如 983674557 / youlianghui_xinxiliu),对不上我们的口径。指标取预估 + Api 两个收益。
|
||||
_DIMENSIONS = "date,site_id,ad_unit_id"
|
||||
_METRICS = "revenue,api_revenue,ecpm,imp_cnt"
|
||||
|
||||
|
||||
def _to_float(v: object) -> float | None:
|
||||
"""接口数值字段都是字符串;空/非法 → None(api_revenue 缺失时即为 None,前端显示「-」)。"""
|
||||
if v is None or str(v).strip() == "":
|
||||
return None
|
||||
try:
|
||||
return float(str(v).strip())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _row_date(row: dict) -> str | None:
|
||||
"""报表行的日期:优先 date 维度,回退 start_date;取前 10 位 'YYYY-MM-DD'。"""
|
||||
raw = row.get("date") or row.get("start_date") or ""
|
||||
raw = str(raw).strip()
|
||||
return raw[:10] if len(raw) >= 10 else None
|
||||
|
||||
|
||||
def _normalize(rows: list[dict]) -> tuple[list[dict], int, set[str]]:
|
||||
"""接口原始行 → 入库行;返回 (入库行, 跳过数, 未映射 site_id 集合)。
|
||||
|
||||
site_id 经 settings 映射成 app_env(prod/test);未映射的仍入库(app_env=site:<id>)但提示,
|
||||
避免静默丢收益。缺 code_id / 日期的行跳过。
|
||||
"""
|
||||
site_map = settings.pangle_report_site_id_to_env
|
||||
out: list[dict] = []
|
||||
skipped = 0
|
||||
unmapped: set[str] = set()
|
||||
for row in rows:
|
||||
rdate = _row_date(row)
|
||||
# 用 ad_unit_id(GroMore 广告位ID = 我们的 104xxx),不是 code_id(底层 ADN 代码位)
|
||||
code_id = str(row.get("ad_unit_id") or "").strip()
|
||||
if not rdate or not code_id:
|
||||
skipped += 1
|
||||
continue
|
||||
site_id = str(row.get("site_id") or "").strip()
|
||||
app_env = site_map.get(site_id)
|
||||
if app_env is None:
|
||||
app_env = f"site:{site_id}" if site_id else "unknown"
|
||||
if site_id:
|
||||
unmapped.add(site_id)
|
||||
out.append({
|
||||
"report_date": rdate,
|
||||
"app_env": app_env,
|
||||
"site_id": site_id or None,
|
||||
"our_code_id": code_id,
|
||||
"adn": "",
|
||||
"revenue_yuan": _to_float(row.get("revenue")) or 0.0,
|
||||
"api_revenue_yuan": _to_float(row.get("api_revenue")),
|
||||
"ecpm": (str(row.get("ecpm")).strip() or None) if row.get("ecpm") is not None else None,
|
||||
"impressions": int(_to_float(row.get("imp_cnt")) or 0),
|
||||
"currency": "cny",
|
||||
})
|
||||
return out, skipped, unmapped
|
||||
|
||||
|
||||
def sync_range(start_date: str, end_date: str) -> None:
|
||||
if not settings.pangle_report_configured:
|
||||
print("PANGLE_REPORT_* 未配置,跳过(no-op)。需在后台「接入中心 → GroMore-API」领取 "
|
||||
"user_id/role_id/Security Key 并填入 .env。")
|
||||
return
|
||||
|
||||
print(f"拉取 GroMore 天级收益 {start_date} ~ {end_date} (dims={_DIMENSIONS}) ...")
|
||||
try:
|
||||
raw = pangle_report.fetch_daily_report(
|
||||
start_date=start_date, end_date=end_date,
|
||||
dimensions=_DIMENSIONS, metrics=_METRICS, time_zone=8,
|
||||
)
|
||||
except PangleReportError as e:
|
||||
print(f"❌ 拉取失败:{e}")
|
||||
sys.exit(1)
|
||||
|
||||
rows, skipped, unmapped = _normalize(raw)
|
||||
if unmapped:
|
||||
print(f"⚠️ 有未映射 site_id(收益仍入库,app_env=site:<id>):{sorted(unmapped)};"
|
||||
f"如需归入 prod/test,补 PANGLE_REPORT_SITE_ID_* 配置。")
|
||||
with SessionLocal() as db:
|
||||
stats = repo.upsert_daily_rows(db, rows)
|
||||
total_rev = round(sum(r["revenue_yuan"] for r in rows), 4)
|
||||
print(f"✅ 完成:接口 {len(raw)} 行 → 入库 {len(rows)} 行(跳过 {skipped}),"
|
||||
f"新增 {stats['inserted']} / 更新 {stats['updated']};预估收益合计 ¥{total_rev}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description="拉取穿山甲 GroMore 天级收益报表入库")
|
||||
ap.add_argument("--date", help="指定单天 YYYY-MM-DD")
|
||||
ap.add_argument("--start", help="区间起始 YYYY-MM-DD(与 --end 同用)")
|
||||
ap.add_argument("--end", help="区间结束 YYYY-MM-DD(与 --start 同用)")
|
||||
ap.add_argument("--days", type=int, help="回补:昨天起往前 N 天(含昨天)")
|
||||
args = ap.parse_args()
|
||||
|
||||
yesterday = cn_today() - timedelta(days=1)
|
||||
if args.date:
|
||||
start = end = args.date
|
||||
elif args.start and args.end:
|
||||
start, end = args.start, args.end
|
||||
elif args.days:
|
||||
start = (yesterday - timedelta(days=args.days - 1)).isoformat()
|
||||
end = yesterday.isoformat()
|
||||
else:
|
||||
start = end = yesterday.isoformat()
|
||||
|
||||
# 防呆:跨度 > 31 天直接拒(接口上限 1 个月,超了会返回 114)
|
||||
if (date.fromisoformat(end) - date.fromisoformat(start)).days > 31:
|
||||
print("❌ 区间跨度超过 31 天(接口上限 1 个月),请分批拉。")
|
||||
sys.exit(1)
|
||||
|
||||
sync_range(start, end)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -305,11 +305,12 @@ def test_feed_reward_grants_by_10_second_units(client) -> None:
|
||||
"duration_seconds": 30,
|
||||
"adn": "pangle",
|
||||
"slot_id": "slot_feed",
|
||||
"display_coin": 4,
|
||||
}
|
||||
r = client.post("/api/v1/ad/feed-reward", json=payload, headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
expected = sum(calculate_ad_reward_coin("200", i) for i in range(1, 4))
|
||||
expected = 4
|
||||
assert body["granted"] is True
|
||||
assert body["status"] == "granted"
|
||||
assert body["unit_count"] == 3
|
||||
|
||||
@@ -64,7 +64,9 @@ def test_dashboard_overview(admin_client: TestClient, admin_token: str) -> None:
|
||||
assert data["users"]["total"] >= 1
|
||||
assert data["coins"]["granted_total"] >= 5000
|
||||
assert "success_rate" in data["comparison"]
|
||||
assert data["cps"]["available"] is False
|
||||
assert data["cps"]["available"] is True
|
||||
assert "meituan_order_count" in data["cps"]
|
||||
assert "jd_order_count" in data["cps"]
|
||||
|
||||
|
||||
def test_user_list_and_detail(admin_client: TestClient, admin_token: str) -> None:
|
||||
@@ -188,6 +190,67 @@ def test_feedback_list(admin_client: TestClient, admin_token: str) -> None:
|
||||
assert all(f["status"] == "pending" for f in r.json()["items"])
|
||||
|
||||
|
||||
def test_feedback_list_filter_by_source(admin_client: TestClient, admin_token: str) -> None:
|
||||
"""反馈列表按反馈类型(source)筛选;返回项带 source/scene 字段。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
uid = user_repo.upsert_user_for_login(db, phone="13800009001", register_channel="sms").id
|
||||
db.add(Feedback(user_id=uid, content="比价反馈", contact="", status="pending",
|
||||
source="comparison", scene="找错商品"))
|
||||
db.add(Feedback(user_id=uid, content="普通反馈", contact="", status="pending",
|
||||
source="profile"))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
r = admin_client.get(
|
||||
"/admin/api/feedbacks", params={"user_id": uid, "source": "comparison"},
|
||||
headers=_auth(admin_token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
items = r.json()["items"]
|
||||
assert items and all(it["source"] == "comparison" for it in items)
|
||||
assert any(it["scene"] == "找错商品" for it in items)
|
||||
|
||||
r = admin_client.get(
|
||||
"/admin/api/feedbacks", params={"user_id": uid, "source": "profile"},
|
||||
headers=_auth(admin_token),
|
||||
)
|
||||
assert all(it["source"] == "profile" for it in r.json()["items"])
|
||||
|
||||
# 非法 source 值 → 422
|
||||
assert admin_client.get(
|
||||
"/admin/api/feedbacks", params={"source": "bogus"}, headers=_auth(admin_token)
|
||||
).status_code == 422
|
||||
|
||||
|
||||
def test_withdraw_list_exposes_source_and_filters(
|
||||
admin_client: TestClient, admin_token: str
|
||||
) -> None:
|
||||
"""提现列表带 source 字段(coin_cash/invite_cash),并可按提现类型筛选。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
uid = user_repo.upsert_user_for_login(db, phone="13800009002", register_channel="sms").id
|
||||
db.add(WithdrawOrder(user_id=uid, out_bill_no=f"src{uid}coin0001",
|
||||
amount_cents=100, status="success", source="coin_cash"))
|
||||
db.add(WithdrawOrder(user_id=uid, out_bill_no=f"src{uid}invite001",
|
||||
amount_cents=200, status="success", source="invite_cash"))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
r = admin_client.get("/admin/api/withdraws", params={"user_id": uid}, headers=_auth(admin_token))
|
||||
assert r.status_code == 200, r.text
|
||||
assert {it["source"] for it in r.json()["items"]} == {"coin_cash", "invite_cash"}
|
||||
|
||||
r = admin_client.get(
|
||||
"/admin/api/withdraws", params={"user_id": uid, "source": "invite_cash"},
|
||||
headers=_auth(admin_token),
|
||||
)
|
||||
items = r.json()["items"]
|
||||
assert items and all(it["source"] == "invite_cash" for it in items)
|
||||
|
||||
|
||||
def test_ad_coin_audit_full_count_truncate_and_only_mismatch(
|
||||
admin_client: TestClient, admin_token: str
|
||||
) -> None:
|
||||
|
||||
@@ -368,6 +368,35 @@ def test_reject_feedback_records_reason_and_no_coin(
|
||||
db.close()
|
||||
|
||||
|
||||
def test_feedback_review_stores_admin_reply(
|
||||
admin_client: TestClient, operator_token: str
|
||||
) -> None:
|
||||
"""审核可给用户留言(admin_reply,用户端可见),采纳/拒绝都能带,并回显在响应里。"""
|
||||
fid = _seed_feedback("13900000021")
|
||||
r = admin_client.post(
|
||||
f"/admin/api/feedbacks/{fid}/approve",
|
||||
json={"reward_coins": 100, "note": "内部备注", "reply": "感谢反馈,已采纳~"},
|
||||
headers=_auth(operator_token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["admin_reply"] == "感谢反馈,已采纳~"
|
||||
db = SessionLocal()
|
||||
try:
|
||||
fb = db.get(Feedback, fid)
|
||||
assert fb.admin_reply == "感谢反馈,已采纳~" and fb.review_note == "内部备注"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
fid2 = _seed_feedback("13900000022")
|
||||
r = admin_client.post(
|
||||
f"/admin/api/feedbacks/{fid2}/reject",
|
||||
json={"reason": "无法复现", "reply": "已收到,后续跟进"},
|
||||
headers=_auth(operator_token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["admin_reply"] == "已收到,后续跟进"
|
||||
|
||||
|
||||
# ===== admin 账号管理(super_admin) =====
|
||||
|
||||
def test_create_and_update_admin(admin_client: TestClient, super_token: str) -> None:
|
||||
|
||||
@@ -44,6 +44,7 @@ def _food_payload(trace_id: str) -> dict:
|
||||
"skipped_dish_names": ["黑牛肉卷"],
|
||||
"total_dish_count": 3,
|
||||
"information": "在美团找到同店,到手价 ¥123.50",
|
||||
"total_ms": 12345,
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +72,8 @@ def test_report_and_derive(client) -> None:
|
||||
assert d["information"] == "在美团找到同店,到手价 ¥123.50"
|
||||
assert d["store_name"] == "海底捞(朝阳店)"
|
||||
assert d["total_dish_count"] == 3
|
||||
assert d["total_ms"] == 12345
|
||||
assert d["raw_payload"]["total_ms"] == 12345
|
||||
assert d["skipped_dish_count"] == 1
|
||||
assert d["skipped_dish_names"] == ["黑牛肉卷"]
|
||||
assert len(d["comparison_results"]) == 3
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.admin.repositories import admin_user as admin_repo
|
||||
from app.admin.repositories import cps as cps_repo
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.cps_link import CpsClick
|
||||
from app.models.cps_order import CpsOrder
|
||||
from app.models.cps_wx_user import CpsWxUser
|
||||
from app.repositories import cps_link as cps_link_repo
|
||||
|
||||
@@ -180,3 +181,73 @@ def test_day_users_cross_year(admin_client: TestClient, admin_token: str) -> Non
|
||||
assert len(users) == 1
|
||||
assert users[0]["openid"] == openid
|
||||
assert users[0]["visit_count"] == 5 # 次年 01-01 那条被时间窗排除
|
||||
|
||||
|
||||
def test_jd_reconcile_updates_dashboard(
|
||||
admin_client: TestClient, admin_token: str, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""京东拉单:按订单行入库;大盘只统计有效 validCode 的佣金。"""
|
||||
|
||||
def fake_query_order_rows(**kwargs):
|
||||
start = kwargs["start_time"].astimezone(_BJ)
|
||||
if start.hour != 10 or kwargs["page_index"] != 1:
|
||||
return {"rows": [], "has_more": False}
|
||||
return {
|
||||
"rows": [
|
||||
{
|
||||
"id": "pytest-jd-row-valid",
|
||||
"orderId": "pytest-jd-order-1",
|
||||
"skuId": "sku-1",
|
||||
"skuName": "京东测试商品",
|
||||
"orderTime": "2026-06-25 10:10:00",
|
||||
"modifyTime": "2026-06-25 10:20:00",
|
||||
"validCode": "16",
|
||||
"estimateCosPrice": "19.90",
|
||||
"estimateFee": "1.23",
|
||||
"actualFee": "2.34",
|
||||
"commissionRate": "10.00",
|
||||
},
|
||||
{
|
||||
"id": "pytest-jd-row-invalid",
|
||||
"orderId": "pytest-jd-order-2",
|
||||
"skuId": "sku-2",
|
||||
"skuName": "京东无效订单",
|
||||
"orderTime": "2026-06-25 10:15:00",
|
||||
"modifyTime": "2026-06-25 10:25:00",
|
||||
"validCode": "4",
|
||||
"estimateCosPrice": "9.90",
|
||||
"estimateFee": "0.50",
|
||||
"commissionRate": "5.00",
|
||||
},
|
||||
],
|
||||
"has_more": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr("app.integrations.jd_union.query_order_rows", fake_query_order_rows)
|
||||
r = admin_client.post(
|
||||
"/admin/api/cps/orders/reconcile",
|
||||
params={
|
||||
"platform": "jd",
|
||||
"date_from": "2026-06-25",
|
||||
"date_to": "2026-06-25",
|
||||
"query_time_type": 1,
|
||||
},
|
||||
headers=_auth(admin_token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["fetched"] == 2
|
||||
|
||||
with SessionLocal() as db:
|
||||
rows = db.query(CpsOrder).filter(CpsOrder.platform == "jd").all()
|
||||
assert len([o for o in rows if o.order_id.startswith("jd:pytest-jd-row")]) == 2
|
||||
|
||||
overview = admin_client.get(
|
||||
"/admin/api/stats/overview",
|
||||
params={"date_from": "2026-06-25", "date_to": "2026-06-25"},
|
||||
headers=_auth(admin_token),
|
||||
)
|
||||
assert overview.status_code == 200, overview.text
|
||||
cps = overview.json()["cps"]
|
||||
assert cps["jd_order_count"] >= 1
|
||||
assert cps["jd_commission_cents"] >= 234
|
||||
assert cps["jd_invalid_count"] >= 1
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1 import device as device_api
|
||||
from app.core import heartbeat_monitor_worker
|
||||
from app.db.session import SessionLocal
|
||||
from app.integrations import vendor_push
|
||||
from app.models.device import DeviceLiveness
|
||||
from app.repositories import user as user_repo
|
||||
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
text = "{}"
|
||||
|
||||
def __init__(self, data: dict) -> None:
|
||||
self._data = data
|
||||
|
||||
def json(self) -> dict:
|
||||
return self._data
|
||||
|
||||
|
||||
def test_xiaomi_accessibility_payload(monkeypatch) -> None:
|
||||
captured: dict = {}
|
||||
|
||||
def _fake_request(method, url, **kwargs): # noqa: ANN001
|
||||
captured.update(method=method, url=url, **kwargs)
|
||||
return _Resp({"code": 0, "result": "ok", "data": {"id": "xm-msg"}})
|
||||
|
||||
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_APP_SECRET", "xiaomi-secret")
|
||||
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_CHANNEL_ID", "")
|
||||
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_ID", "")
|
||||
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_TITLE", "")
|
||||
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_DESCRIPTION", "")
|
||||
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_PARAM_JSON", "")
|
||||
monkeypatch.setattr(vendor_push.httpx, "request", _fake_request)
|
||||
|
||||
data = vendor_push.send_accessibility_disabled("xiaomi", "xm-regid")
|
||||
|
||||
assert data["data"]["id"] == "xm-msg"
|
||||
assert captured["method"] == "POST"
|
||||
assert captured["url"] == vendor_push.settings.XIAOMI_PUSH_SEND_ENDPOINT
|
||||
assert captured["headers"]["Authorization"] == "key=xiaomi-secret"
|
||||
body = captured["data"]
|
||||
assert body["registration_id"] == "xm-regid"
|
||||
assert body["restricted_package_name"] == "com.jishisongfu.shaguabijia"
|
||||
assert json.loads(body["payload"]) == {"type": "accessibility_disabled"}
|
||||
assert "extra.channel_id" not in body
|
||||
|
||||
|
||||
def test_xiaomi_payload_with_channel_and_template(monkeypatch) -> None:
|
||||
captured: dict = {}
|
||||
|
||||
def _fake_request(method, url, **kwargs): # noqa: ANN001
|
||||
captured.update(method=method, url=url, **kwargs)
|
||||
return _Resp({"code": 0, "result": "ok", "data": {"id": "xm-msg"}})
|
||||
|
||||
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_APP_SECRET", "xiaomi-secret")
|
||||
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_CHANNEL_ID", "130")
|
||||
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_ID", "1001")
|
||||
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_TITLE", "{$app_name$}提醒")
|
||||
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_DESCRIPTION", "{$content$}")
|
||||
monkeypatch.setattr(
|
||||
vendor_push.settings,
|
||||
"XIAOMI_PUSH_TEMPLATE_PARAM_JSON",
|
||||
'{"app_name":"傻瓜比价","content":"{alert}"}',
|
||||
)
|
||||
monkeypatch.setattr(vendor_push.httpx, "request", _fake_request)
|
||||
|
||||
vendor_push.send_accessibility_disabled(
|
||||
"xiaomi",
|
||||
"xm-regid",
|
||||
title="测试标题",
|
||||
alert="测试内容",
|
||||
)
|
||||
|
||||
body = captured["data"]
|
||||
assert body["title"] == "{$app_name$}提醒"
|
||||
assert body["description"] == "{$content$}"
|
||||
assert body["extra.channel_id"] == "130"
|
||||
assert body["extra.template_id"] == "1001"
|
||||
assert body["extra.template_param"] == '{"app_name":"傻瓜比价","content":"测试内容"}'
|
||||
|
||||
|
||||
def test_vivo_auth_and_send_payload(monkeypatch) -> None:
|
||||
vendor_push._token_cache.clear()
|
||||
calls: list[dict] = []
|
||||
|
||||
def _fake_request(method, url, **kwargs): # noqa: ANN001
|
||||
calls.append({"method": method, "url": url, **kwargs})
|
||||
if url == vendor_push.settings.VIVO_PUSH_AUTH_ENDPOINT:
|
||||
return _Resp({"result": 0, "authToken": "vivo-auth"})
|
||||
return _Resp({"result": 0, "taskId": "vivo-task"})
|
||||
|
||||
monkeypatch.setattr(vendor_push.settings, "VIVO_PUSH_APP_ID", "106072775")
|
||||
monkeypatch.setattr(vendor_push.settings, "VIVO_PUSH_APP_KEY", "vivo-key")
|
||||
monkeypatch.setattr(vendor_push.settings, "VIVO_PUSH_APP_SECRET", "vivo-secret")
|
||||
monkeypatch.setattr(vendor_push.httpx, "request", _fake_request)
|
||||
|
||||
data = vendor_push.send_accessibility_disabled("vivo", "vivo-regid")
|
||||
|
||||
assert data["taskId"] == "vivo-task"
|
||||
assert calls[0]["url"] == vendor_push.settings.VIVO_PUSH_AUTH_ENDPOINT
|
||||
assert calls[0]["json"]["appId"] == "106072775"
|
||||
assert calls[0]["json"]["sign"]
|
||||
assert calls[1]["url"] == vendor_push.settings.VIVO_PUSH_SEND_ENDPOINT
|
||||
assert calls[1]["headers"]["authToken"] == "vivo-auth"
|
||||
body = calls[1]["json"]
|
||||
assert body["regId"] == "vivo-regid"
|
||||
assert body["pushMode"] == vendor_push.settings.VIVO_PUSH_MODE
|
||||
assert body["clientCustomMap"] == {"type": "accessibility_disabled"}
|
||||
|
||||
|
||||
def test_oppo_auth_and_send_payload(monkeypatch) -> None:
|
||||
vendor_push._token_cache.clear()
|
||||
calls: list[dict] = []
|
||||
|
||||
def _fake_request(method, url, **kwargs): # noqa: ANN001
|
||||
calls.append({"method": method, "url": url, **kwargs})
|
||||
if url == vendor_push.settings.OPPO_PUSH_AUTH_ENDPOINT:
|
||||
return _Resp({"code": 0, "data": {"auth_token": "oppo-auth"}})
|
||||
return _Resp({"code": 0, "data": {"message_id": "oppo-msg"}})
|
||||
|
||||
monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_APP_KEY", "oppo-key")
|
||||
monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_MASTER_SECRET", "oppo-master")
|
||||
monkeypatch.setattr(vendor_push.httpx, "request", _fake_request)
|
||||
|
||||
data = vendor_push.send_accessibility_disabled("oppo", "oppo-regid")
|
||||
|
||||
assert data["data"]["message_id"] == "oppo-msg"
|
||||
assert calls[0]["data"]["app_key"] == "oppo-key"
|
||||
assert calls[0]["data"]["sign"]
|
||||
message = json.loads(calls[1]["data"]["message"])
|
||||
assert calls[1]["data"]["auth_token"] == "oppo-auth"
|
||||
assert message["target_type"] == 2
|
||||
assert message["target_value"] == "oppo-regid"
|
||||
assert json.loads(message["notification"]["action_parameters"]) == {
|
||||
"type": "accessibility_disabled"
|
||||
}
|
||||
|
||||
|
||||
def test_honor_auth_and_send_payload(monkeypatch) -> None:
|
||||
vendor_push._token_cache.clear()
|
||||
calls: list[dict] = []
|
||||
|
||||
def _fake_request(method, url, **kwargs): # noqa: ANN001
|
||||
calls.append({"method": method, "url": url, **kwargs})
|
||||
if url == vendor_push.settings.HONOR_PUSH_TOKEN_ENDPOINT:
|
||||
return _Resp({"access_token": "honor-access", "expires_in": 3600})
|
||||
return _Resp({"code": 200, "message": "successful!", "data": {"sendResult": True}})
|
||||
|
||||
monkeypatch.setattr(vendor_push.settings, "HONOR_PUSH_APP_ID", "104559789")
|
||||
monkeypatch.setattr(vendor_push.settings, "HONOR_PUSH_CLIENT_ID", "honor-client")
|
||||
monkeypatch.setattr(vendor_push.settings, "HONOR_PUSH_CLIENT_SECRET", "honor-secret")
|
||||
monkeypatch.setattr(vendor_push.httpx, "request", _fake_request)
|
||||
|
||||
data = vendor_push.send_accessibility_disabled("honor", "honor-token")
|
||||
|
||||
assert data["code"] == 200
|
||||
assert calls[0]["data"]["client_id"] == "honor-client"
|
||||
assert calls[1]["headers"]["Authorization"] == "Bearer honor-access"
|
||||
assert calls[1]["headers"]["timestamp"]
|
||||
assert calls[1]["url"].endswith("/api/v1/104559789/sendMessage")
|
||||
body = calls[1]["json"]
|
||||
assert body["token"] == ["honor-token"]
|
||||
assert body["android"]["targetUserType"] == 1
|
||||
assert body["android"]["notification"]["clickAction"] == {"type": 3}
|
||||
assert json.loads(body["data"]) == {"type": "accessibility_disabled"}
|
||||
|
||||
|
||||
def _seed_overdue_device(
|
||||
*,
|
||||
phone: str,
|
||||
device_id: str,
|
||||
push_vendor: str | None,
|
||||
push_token: str | None,
|
||||
) -> int:
|
||||
with SessionLocal() as db:
|
||||
user = user_repo.upsert_user_for_login(db, phone=phone, register_channel="sms")
|
||||
device = DeviceLiveness(
|
||||
user_id=user.id,
|
||||
device_id=device_id,
|
||||
push_vendor=push_vendor,
|
||||
push_token=push_token,
|
||||
platform="android",
|
||||
ever_protected=True,
|
||||
last_heartbeat_at=datetime.now(timezone.utc) - timedelta(minutes=30), # noqa: UP017
|
||||
last_report_protection_on=True,
|
||||
liveness_state="alive",
|
||||
kill_alert_pending=False,
|
||||
)
|
||||
db.add(device)
|
||||
db.commit()
|
||||
db.refresh(device)
|
||||
return device.id
|
||||
|
||||
|
||||
def _login(client: TestClient, phone: str) -> str:
|
||||
client.post("/api/v1/auth/sms/send", json={"phone": phone})
|
||||
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def _auth(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def test_heartbeat_monitor_pushes_overdue_device(monkeypatch) -> None:
|
||||
device_pk = _seed_overdue_device(
|
||||
phone="13900009001",
|
||||
device_id="dev-push-honor",
|
||||
push_vendor="honor",
|
||||
push_token="honor-token-1",
|
||||
)
|
||||
calls: list[tuple[str, str]] = []
|
||||
|
||||
def _fake_send(push_vendor: str, push_token: str) -> dict:
|
||||
calls.append((push_vendor, push_token))
|
||||
return {"msg_id": "m1"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
heartbeat_monitor_worker.vendor_push,
|
||||
"send_accessibility_disabled",
|
||||
_fake_send,
|
||||
)
|
||||
|
||||
result = heartbeat_monitor_worker._scan_once(timeout_minutes=10)
|
||||
|
||||
assert result["pushed"] >= 1
|
||||
assert ("honor", "honor-token-1") in calls
|
||||
with SessionLocal() as db:
|
||||
device = db.get(DeviceLiveness, device_pk)
|
||||
assert device is not None
|
||||
assert device.liveness_state == "notified"
|
||||
assert device.kill_alert_pending is True
|
||||
|
||||
|
||||
def test_heartbeat_monitor_skips_push_without_vendor_token(monkeypatch) -> None:
|
||||
device_pk = _seed_overdue_device(
|
||||
phone="13900009002",
|
||||
device_id="dev-push-no-token",
|
||||
push_vendor=None,
|
||||
push_token=None,
|
||||
)
|
||||
|
||||
def _fake_send(push_vendor: str, push_token: str) -> dict:
|
||||
raise AssertionError(f"should not push without token: {push_vendor}/{push_token}")
|
||||
|
||||
monkeypatch.setattr(
|
||||
heartbeat_monitor_worker.vendor_push,
|
||||
"send_accessibility_disabled",
|
||||
_fake_send,
|
||||
)
|
||||
|
||||
result = heartbeat_monitor_worker._scan_once(timeout_minutes=10)
|
||||
|
||||
assert result["checked"] >= 1
|
||||
with SessionLocal() as db:
|
||||
device = db.get(DeviceLiveness, device_pk)
|
||||
assert device is not None
|
||||
assert device.liveness_state == "notified"
|
||||
assert device.kill_alert_pending is True
|
||||
|
||||
|
||||
def test_push_test_endpoint_schedules_vendor_push(client: TestClient, monkeypatch) -> None:
|
||||
token = _login(client, "13900009003")
|
||||
calls: list[tuple[str, str, str, str]] = []
|
||||
|
||||
def _fake_send(push_vendor: str, push_token: str, *, title: str, alert: str) -> dict:
|
||||
calls.append((push_vendor, push_token, title, alert))
|
||||
return {"msg_id": "m-test"}
|
||||
|
||||
monkeypatch.setattr(device_api.vendor_push, "send_accessibility_disabled", _fake_send)
|
||||
|
||||
r = client.post(
|
||||
"/api/v1/device/push-test",
|
||||
json={
|
||||
"device_id": "dev-push-test",
|
||||
"push_vendor": "honor",
|
||||
"push_token": "honor-test-token",
|
||||
"delay_seconds": 0,
|
||||
},
|
||||
headers=_auth(token),
|
||||
)
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {
|
||||
"ok": True,
|
||||
"delay_seconds": 0,
|
||||
"has_push_token": True,
|
||||
}
|
||||
assert calls == [
|
||||
(
|
||||
"honor",
|
||||
"honor-test-token",
|
||||
"测试推送",
|
||||
"这是一条厂商通道测试推送。收到它说明 App 被划掉后仍可通过系统通知栏触达。",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_push_test_endpoint_requires_vendor_token(client: TestClient) -> None:
|
||||
token = _login(client, "13900009004")
|
||||
|
||||
r = client.post(
|
||||
"/api/v1/device/push-test",
|
||||
json={"device_id": "dev-push-test-no-token", "delay_seconds": 0},
|
||||
headers=_auth(token),
|
||||
)
|
||||
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"] == "push vendor token not ready"
|
||||
@@ -36,6 +36,43 @@ def test_feedback_config_returns_default(client) -> None:
|
||||
assert body["group_name"] == "傻瓜比价官方群"
|
||||
|
||||
|
||||
def test_submit_feedback_derives_source_from_scene(client) -> None:
|
||||
"""比价结果页反馈带 scene → source=comparison 并存 scene;「我的」页不带 → profile;
|
||||
客户端显式传 source 优先(即便没 scene 也判 comparison)。历史接口带回 scene。"""
|
||||
token = _login(client, "13622000010")
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
r = client.post("/api/v1/feedback", data={"content": "比价太慢了", "scene": "比价太慢"}, headers=h)
|
||||
assert r.status_code == 200, r.text
|
||||
fid_cmp = r.json()["id"]
|
||||
|
||||
r = client.post("/api/v1/feedback", data={"content": "普通建议"}, headers=h)
|
||||
assert r.status_code == 200, r.text
|
||||
fid_prof = r.json()["id"]
|
||||
|
||||
r = client.post(
|
||||
"/api/v1/feedback", data={"content": "只填了描述", "source": "comparison"}, headers=h
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
fid_explicit = r.json()["id"]
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
cmp_fb = db.get(Feedback, fid_cmp)
|
||||
assert cmp_fb.source == "comparison" and cmp_fb.scene == "比价太慢"
|
||||
prof_fb = db.get(Feedback, fid_prof)
|
||||
assert prof_fb.source == "profile" and prof_fb.scene is None
|
||||
assert db.get(Feedback, fid_explicit).source == "comparison"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
r = client.get("/api/v1/feedback/records", headers=h)
|
||||
assert r.status_code == 200, r.text
|
||||
scenes = {item["id"]: item["scene"] for item in r.json()["records"]}
|
||||
assert scenes[fid_cmp] == "比价太慢"
|
||||
assert scenes[fid_prof] is None
|
||||
|
||||
|
||||
def test_feedback_records_maps_existing_statuses(client) -> None:
|
||||
phone = "13622000003"
|
||||
token = _login(client, phone)
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"""消息通知中心 3 接口(mock 数据阶段)。
|
||||
|
||||
覆盖:列表字段/排序/分页、未读角标、标记已读(ids / all / 幂等 / 参数校验)、
|
||||
鉴权与用户间隔离。数据来自 repositories/notification_mock.py 的内存 mock。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core import notification_catalog as catalog
|
||||
from app.repositories import notification_mock as mock_repo
|
||||
|
||||
|
||||
def _login(client: TestClient, phone: str) -> str:
|
||||
client.post("/api/v1/auth/sms/send", json={"phone": phone})
|
||||
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def _auth(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _fetch_all(client: TestClient, token: str) -> dict:
|
||||
r = client.get("/api/v1/notifications?pageSize=100", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def test_requires_auth(client: TestClient) -> None:
|
||||
assert client.get("/api/v1/notifications").status_code == 401
|
||||
assert client.get("/api/v1/notifications/unread-count").status_code == 401
|
||||
assert client.post("/api/v1/notifications/read", json={"all": True}).status_code == 401
|
||||
|
||||
|
||||
def test_list_covers_all_types_with_camel_case_fields(client: TestClient) -> None:
|
||||
token = _login(client, "13900010001")
|
||||
data = _fetch_all(client, token)
|
||||
|
||||
assert data["total"] >= 13
|
||||
assert data["unreadCount"] > 0
|
||||
items = data["items"]
|
||||
assert len(items) == data["total"]
|
||||
|
||||
# 13 种类型全覆盖
|
||||
assert {i["type"] for i in items} == set(catalog.TYPES)
|
||||
|
||||
# 字段按 PRD 契约 camelCase,且卡片要素齐全
|
||||
first = items[0]
|
||||
for key in (
|
||||
"id", "category", "categoryLabel", "type", "cardStyle", "title",
|
||||
"coins", "cashCents", "cashYuan", "infoRows", "actionText",
|
||||
"extra", "sentAt", "isRead",
|
||||
):
|
||||
assert key in first, f"missing field {key}"
|
||||
assert "+08:00" in first["sentAt"]
|
||||
|
||||
# 双金额卡:金币 + 现金都有;金币为整数、现金串两位小数
|
||||
expiring = next(i for i in items if i["type"] == "reward_expiring")
|
||||
assert isinstance(expiring["coins"], int)
|
||||
assert expiring["cashYuan"] == "12.80"
|
||||
assert expiring["cashCents"] == 1280
|
||||
assert expiring["actionText"] == "立即激活您的收益"
|
||||
assert [row["label"] for row in expiring["infoRows"]] == ["过期说明", "过期时间"]
|
||||
|
||||
# 提现成功卡:无操作行、无跳转
|
||||
ws = next(i for i in items if i["type"] == "withdraw_success")
|
||||
assert ws["actionText"] is None
|
||||
assert ws["coins"] is None
|
||||
|
||||
# 权限异常卡带 permission 参数(客户端点击时实时检测用)
|
||||
perm = next(i for i in items if i["type"] == "perm_accessibility")
|
||||
assert perm["extra"] == {"permission": "accessibility"}
|
||||
|
||||
# 反馈奖励卡:官方留言必填(PRD §3)
|
||||
reward = next(i for i in items if i["type"] == "feedback_reward")
|
||||
assert any(row["label"] == "官方留言" and row["value"] for row in reward["infoRows"])
|
||||
|
||||
|
||||
def test_list_sorted_by_time_desc_without_grouping(client: TestClient) -> None:
|
||||
"""全列表时间倒序、不按分类分组(PRD 原文的分组已确认取消)。"""
|
||||
token = _login(client, "13900010002")
|
||||
items = _fetch_all(client, token)["items"]
|
||||
|
||||
sent_ats = [i["sentAt"] for i in items]
|
||||
assert sent_ats == sorted(sent_ats, reverse=True), "全列表时间倒序,最新在前"
|
||||
|
||||
# mock 样例里「好友下单(10 分钟前)」比「昨天的提现失败」新但分类靠后 →
|
||||
# 若还在按分类分组,它不可能排进前 3;以此钉死"确实没分组"。
|
||||
newest_types = {i["type"] for i in items[:3]}
|
||||
assert "invite_order_reward" in newest_types
|
||||
|
||||
|
||||
def test_pagination(client: TestClient) -> None:
|
||||
token = _login(client, "13900010003")
|
||||
total = _fetch_all(client, token)["total"]
|
||||
|
||||
page_size = 5
|
||||
seen_ids: list[int] = []
|
||||
page = 1
|
||||
while True:
|
||||
r = client.get(
|
||||
f"/api/v1/notifications?page={page}&pageSize={page_size}", headers=_auth(token)
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["page"] == page
|
||||
assert data["pageSize"] == page_size
|
||||
assert data["total"] == total
|
||||
seen_ids.extend(i["id"] for i in data["items"])
|
||||
if not data["hasMore"]:
|
||||
assert len(data["items"]) <= page_size
|
||||
break
|
||||
assert len(data["items"]) == page_size
|
||||
page += 1
|
||||
|
||||
assert len(seen_ids) == total
|
||||
assert len(set(seen_ids)) == total, "翻页不重不漏"
|
||||
|
||||
# 超出末页 → 空页而非报错
|
||||
r = client.get("/api/v1/notifications?page=99&pageSize=50", headers=_auth(token))
|
||||
assert r.status_code == 200
|
||||
assert r.json()["items"] == []
|
||||
assert r.json()["hasMore"] is False
|
||||
|
||||
|
||||
def test_unread_count_and_badge(client: TestClient) -> None:
|
||||
token = _login(client, "13900010004")
|
||||
data = _fetch_all(client, token)
|
||||
expect_unread = sum(1 for i in data["items"] if not i["isRead"])
|
||||
|
||||
r = client.get("/api/v1/notifications/unread-count", headers=_auth(token))
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["count"] == expect_unread == data["unreadCount"]
|
||||
assert body["badgeText"] == str(expect_unread) # mock 样例未读数在 1~99 区间
|
||||
|
||||
# 全部读完 → count=0,badgeText=null(整个角标隐藏)
|
||||
client.post("/api/v1/notifications/read", json={"all": True}, headers=_auth(token))
|
||||
r = client.get("/api/v1/notifications/unread-count", headers=_auth(token))
|
||||
assert r.json() == {"count": 0, "badgeText": None}
|
||||
|
||||
|
||||
def test_mark_read_by_ids_idempotent(client: TestClient) -> None:
|
||||
token = _login(client, "13900010005")
|
||||
items = _fetch_all(client, token)["items"]
|
||||
unread_ids = [i["id"] for i in items if not i["isRead"]]
|
||||
before = len(unread_ids)
|
||||
picked = unread_ids[:2]
|
||||
|
||||
r = client.post("/api/v1/notifications/read", json={"ids": picked}, headers=_auth(token))
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"ok": True, "markedCount": 2, "unreadCount": before - 2}
|
||||
|
||||
# 列表状态同步翻转
|
||||
items = _fetch_all(client, token)["items"]
|
||||
assert all(i["isRead"] for i in items if i["id"] in picked)
|
||||
|
||||
# 重复置读 + 不存在的 id → 幂等,不报错
|
||||
r = client.post(
|
||||
"/api/v1/notifications/read", json={"ids": [*picked, 123456789]}, headers=_auth(token)
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["markedCount"] == 0
|
||||
assert r.json()["unreadCount"] == before - 2
|
||||
|
||||
|
||||
def test_mark_read_requires_ids_or_all(client: TestClient) -> None:
|
||||
token = _login(client, "13900010006")
|
||||
r = client.post("/api/v1/notifications/read", json={}, headers=_auth(token))
|
||||
assert r.status_code == 400
|
||||
r = client.post("/api/v1/notifications/read", json={"ids": []}, headers=_auth(token))
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_stores_isolated_between_users(client: TestClient) -> None:
|
||||
token_a = _login(client, "13900010007")
|
||||
token_b = _login(client, "13900010008")
|
||||
|
||||
client.post("/api/v1/notifications/read", json={"all": True}, headers=_auth(token_a))
|
||||
|
||||
r = client.get("/api/v1/notifications/unread-count", headers=_auth(token_b))
|
||||
assert r.json()["count"] > 0, "A 清零不影响 B"
|
||||
|
||||
|
||||
def test_reset_store_regenerates(client: TestClient) -> None:
|
||||
token = _login(client, "13900010009")
|
||||
client.post("/api/v1/notifications/read", json={"all": True}, headers=_auth(token))
|
||||
assert client.get("/api/v1/notifications/unread-count", headers=_auth(token)).json()["count"] == 0
|
||||
|
||||
mock_repo.reset_store() # 联调辅助:清空后重新生成整套样例
|
||||
assert client.get("/api/v1/notifications/unread-count", headers=_auth(token)).json()["count"] > 0
|
||||
@@ -0,0 +1,134 @@
|
||||
"""穿山甲 GroMore 数据 API 客户端测试。
|
||||
|
||||
重点锚定**签名算法**:用官方文档 Python / Java 示例里给出的「参数 + secure_key → sign」两个
|
||||
测试向量做断言,保证我们的 build_sign 与穿山甲服务端逐字节一致(签错=接口直接 101 验签失败)。
|
||||
另测拉取的翻页、业务码非 100 抛错、响应解析。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.config import settings
|
||||
from app.integrations import pangle_report
|
||||
from app.integrations.pangle_report import PangleReportError, build_sign
|
||||
|
||||
# ---- 签名:官方文档测试向量(完整 key 与期望 sign 来自文档代码注释)----
|
||||
|
||||
def test_build_sign_matches_doc_python_vector():
|
||||
params = {
|
||||
"current_time": "2022-01-06 16:10:54",
|
||||
"sign_type": "MD5",
|
||||
"version": "2.0",
|
||||
"user_id": 459,
|
||||
"role_id": 459,
|
||||
}
|
||||
sign = build_sign(params, "7f2ddb34d999ea61c00246ea3971529c")
|
||||
assert sign == "57b9fed9b6c09cefdced7fa770fe341d"
|
||||
|
||||
|
||||
def test_build_sign_matches_doc_java_vector():
|
||||
params = {
|
||||
"user_id": 459,
|
||||
"role_id": 459,
|
||||
"version": "2.0",
|
||||
"sign_type": "MD5",
|
||||
"current_time": "2022-01-06 16:05:49",
|
||||
}
|
||||
sign = build_sign(params, "1c589c70f7bb2746027ce90c33d2544c")
|
||||
assert sign == "4d47b3a7679448c3da5c1d05e5291533"
|
||||
|
||||
|
||||
def test_build_sign_drops_sign_and_empty_fields():
|
||||
"""sign 字段与空值字段不参与签名:带上它们应得到与不带时相同的 sign。"""
|
||||
base = {"version": "2.0", "user_id": 459, "role_id": 459, "sign_type": "MD5"}
|
||||
with_noise = {**base, "sign": "deadbeef", "code_ids": "", "os": None}
|
||||
assert build_sign(with_noise, "key123") == build_sign(base, "key123")
|
||||
|
||||
|
||||
# ---- 拉取:伪造 httpx 客户端,验证翻页 / 解析 / 业务码 ----
|
||||
|
||||
class _FakeResp:
|
||||
def __init__(self, payload: dict):
|
||||
self._payload = payload
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
pass
|
||||
|
||||
def json(self) -> dict:
|
||||
return self._payload
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
"""按 offset 返回分页响应;记录每次请求的 params 供断言。"""
|
||||
|
||||
def __init__(self, pages: list[dict]):
|
||||
self._pages = pages
|
||||
self.calls: list[dict] = []
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def get(self, url: str, params: dict):
|
||||
self.calls.append(params)
|
||||
# 第 N 次调用取第 N 页(has_next 驱动翻页);超出用最后一页兜底
|
||||
page = self._pages[min(len(self.calls) - 1, len(self._pages) - 1)]
|
||||
return _FakeResp(page)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _configured(monkeypatch):
|
||||
monkeypatch.setattr(settings, "PANGLE_REPORT_USER_ID", 459)
|
||||
monkeypatch.setattr(settings, "PANGLE_REPORT_ROLE_ID", 459)
|
||||
monkeypatch.setattr(settings, "PANGLE_REPORT_SECURITY_KEY", "key123")
|
||||
|
||||
|
||||
def test_fetch_daily_report_paginates_and_parses(monkeypatch, _configured):
|
||||
page1 = {
|
||||
"code": "100", "message": "",
|
||||
"data": {
|
||||
"has_next": "1", "currency": "cny", "total": 3,
|
||||
"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"},
|
||||
],
|
||||
},
|
||||
}
|
||||
page2 = {
|
||||
"code": "100", "message": "",
|
||||
"data": {
|
||||
"has_next": "0", "currency": "cny", "total": 3,
|
||||
"report_list": [
|
||||
{"start_date": "2026-06-27", "site_id": "5832303", "ad_unit_id": "104137445",
|
||||
"revenue": "0.00", "api_revenue": "", "ecpm": "0", "imp_cnt": "12"},
|
||||
],
|
||||
},
|
||||
}
|
||||
fake = _FakeClient([page1, page2])
|
||||
monkeypatch.setattr(pangle_report.httpx, "Client", lambda *a, **k: fake)
|
||||
|
||||
rows = pangle_report.fetch_daily_report(start_date="2026-06-27", end_date="2026-06-27")
|
||||
|
||||
assert len(rows) == 2 # 两页都被取到(has_next 驱动翻页)
|
||||
assert rows[0]["ad_unit_id"] == "104142227"
|
||||
assert rows[1]["api_revenue"] == ""
|
||||
# 每次请求都带上签名与必填鉴权参数
|
||||
assert "sign" in fake.calls[0]
|
||||
assert fake.calls[0]["user_id"] == 459 and fake.calls[0]["version"] == "2.0"
|
||||
|
||||
|
||||
def test_fetch_daily_report_raises_on_business_error(monkeypatch, _configured):
|
||||
fail = {"code": "118", "message": "no revenue permission", "data": {}}
|
||||
monkeypatch.setattr(
|
||||
pangle_report.httpx, "Client", lambda *a, **k: _FakeClient([fail])
|
||||
)
|
||||
with pytest.raises(PangleReportError):
|
||||
pangle_report.fetch_daily_report(start_date="2026-06-27", end_date="2026-06-27")
|
||||
|
||||
|
||||
def test_fetch_daily_report_no_config_raises(monkeypatch):
|
||||
monkeypatch.setattr(settings, "PANGLE_REPORT_USER_ID", 0)
|
||||
with pytest.raises(PangleReportError):
|
||||
pangle_report.fetch_daily_report(start_date="2026-06-27", end_date="2026-06-27")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user