Compare commits

..

1 Commits

Author SHA1 Message Date
zzhyyyyy 280e418ccf admin 反馈/低价审核接口增强 + 反馈采集端环境(版本/机型)
- 反馈:新增 GET /admin/api/feedbacks/summary(待审核/已采纳/未采纳/合计计数);
  列表联表带出完整手机号 + 昵称(供 admin 点手机号查该用户全部反馈)
- 低价审核:列表加 sort_by/sort_order(提交时间排序);联表带出手机号/昵称,
  并按 comparison_record_id 关联比价记录带出 trace_id/trace_url + 机型/ROM/Android/app 版本
- feedback 表新增 app_version/device_model/rom_name/android_version 四列 + alembic 迁移;
  /api/v1/feedback 提交接口接收并落库,供 admin 反馈页展示「提交版本号」「机型OS版本」
  (字段可空,旧端不传不影响提交)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 22:40:16 +08:00
56 changed files with 119 additions and 2153 deletions
+1 -8
View File
@@ -29,7 +29,7 @@ JG_REQUEST_TIMEOUT_SEC=15
# ===== 无障碍保护存活监控(pull 后置检测;本期不接推送)=====
HEARTBEAT_MONITOR_ENABLED=true
HEARTBEAT_TIMEOUT_MINUTES=60
HEARTBEAT_TIMEOUT_MINUTES=10
HEARTBEAT_SCAN_INTERVAL_SEC=60
# ===== 短信 (mock 模式) =====
@@ -58,13 +58,6 @@ 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)。
+2 -5
View File
@@ -23,17 +23,14 @@ dist/
*.db-journal
*.db-shm
*.db-wal
# data/ 整体不入库(运行时数据/上传文件/大二进制)。例外:邀请落地页 dl.html 及其
# 引用的静态插画(coupon-page-bg.png 底图 + sb-brand.png logo)——既是生产落地页资产
# 又是本地测试资产,纳入 git 便于同事一致测试、且随部署进生产 /media(否则线上 404→落地页毛坯)。
# data/ 整体不入库(运行时数据/上传文件/大二进制)。例外:邀请落地页 dl.html——
# 它既是生产落地页又是本地测试资产,纳入 git 便于同事一致测试
# (见 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
@@ -1,26 +0,0 @@
"""merge jd_cps_order_fields and coupon_session_origin_package heads
Revision ID: 761ef181ce7c
Revises: coupon_session_origin_package, jd_cps_order_fields
Create Date: 2026-07-01 13:52:16.068808
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '761ef181ce7c'
down_revision: Union[str, Sequence[str], None] = ('coupon_session_origin_package', 'jd_cps_order_fields')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -1,32 +0,0 @@
"""coupon_session 加 origin_package 列(发起来源 App 包名 → admin「发起平台」)
null=App 内(傻瓜比价首页)发起,非空=从美团/淘宝/京东弹券发起。与 trace_url 同理单独成迁移,
已建表环境靠它补列、全新环境顺序应用,不重复加列。
Revision ID: coupon_session_origin_package
Revises: coupon_session_trace_url
Create Date: 2026-06-30 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "coupon_session_origin_package"
down_revision: str | Sequence[str] | None = "coupon_session_trace_url"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
batch_op.add_column(sa.Column("origin_package", sa.String(length=64), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
batch_op.drop_column("origin_package")
-81
View File
@@ -1,81 +0,0 @@
"""coupon session table(领券任务全程流水 → admin「领券数据」看板数据源)
一次领券一行(trace_id 唯一):客户端 POST /api/v1/coupon/session 两段上报 —— 发起建行
(status=started)、收尾(completed/failed/abandoned)按 trace_id 更新同一行。记全程耗时
elapsed_ms + 各平台耗时 platform_elapsed + 机型/ROM,供 admin 算发起/完成数、耗时分位、机型维度。
Revision ID: coupon_session_table
Revises: feedback_submit_env
Create Date: 2026-06-30 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "coupon_session_table"
down_revision: str | Sequence[str] | None = "feedback_submit_env"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# PG 用 JSONB,SQLite(本地/测试)退化为通用 JSON(同 model 的 _JSON variant)。
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
def upgrade() -> None:
op.create_table(
"coupon_session",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("trace_id", sa.String(length=64), nullable=False),
sa.Column("device_id", sa.String(length=64), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("app_env", sa.String(length=16), nullable=True),
sa.Column("platforms", _JSON, nullable=True),
sa.Column("device_model", sa.String(length=128), nullable=True),
sa.Column("rom", sa.String(length=64), nullable=True),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("started_date", sa.Date(), nullable=False),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("elapsed_ms", sa.Integer(), nullable=True),
sa.Column("platform_elapsed", _JSON, nullable=True),
sa.Column("claimed_count", sa.Integer(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("trace_id", name="uq_coupon_session_trace"),
)
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
batch_op.create_index(
batch_op.f("ix_coupon_session_user_id"), ["user_id"], unique=False
)
batch_op.create_index(
batch_op.f("ix_coupon_session_app_env"), ["app_env"], unique=False
)
# admin 主聚合/筛选:按上海自然日 + 环境。
batch_op.create_index(
"ix_coupon_session_date_env", ["started_date", "app_env"], unique=False
)
def downgrade() -> None:
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
batch_op.drop_index("ix_coupon_session_date_env")
batch_op.drop_index(batch_op.f("ix_coupon_session_app_env"))
batch_op.drop_index(batch_op.f("ix_coupon_session_user_id"))
op.drop_table("coupon_session")
@@ -1,32 +0,0 @@
"""coupon_session 加 trace_url 列(pricebot done 帧公网调试链接)
建表迁移 coupon_session_table 落地后才追加本列,故单独成一个迁移:已建表的环境(本地/已 upgrade 过)
靠它补列,全新环境则「建表(无 trace_url)→ 本迁移加列」,两条路一致、不重复加列。
Revision ID: coupon_session_trace_url
Revises: coupon_session_table
Create Date: 2026-06-30 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "coupon_session_trace_url"
down_revision: str | Sequence[str] | None = "coupon_session_table"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
batch_op.add_column(sa.Column("trace_url", sa.String(length=512), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
batch_op.drop_column("trace_url")
-48
View File
@@ -1,48 +0,0 @@
"""feedback 加反馈来源/场景/运营回复(source / scene / admin_reply)
admin「用户反馈」页要展示并筛选「反馈类型」(比价反馈 / 普通反馈),并支持审核时给用户留言:
- source: 反馈来源入口(profile=「我的」页 / comparison=比价结果页)。NOT NULL,
旧数据 + 普通反馈默认 profile(server_default)。加索引供 admin 按类型筛选。
- scene: 比价反馈的问题场景(找错商品/优惠不对…),普通反馈为 NULL。
- admin_reply: 运营给用户的回复留言(用户端可见),随「我的反馈」历史下发。
均为新增列(SQLite 原生支持 add_column);downgrade 的 drop_column 在 SQLite 走 batch 兜底。
Revision ID: feedback_type_reply
Revises: 761ef181ce7c
Create Date: 2026-07-02 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "feedback_type_reply"
down_revision: str | Sequence[str] | None = "761ef181ce7c"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"feedback",
sa.Column(
"source",
sa.String(length=16),
nullable=False,
server_default="profile",
),
)
op.add_column("feedback", sa.Column("scene", sa.String(length=32), nullable=True))
op.add_column("feedback", sa.Column("admin_reply", sa.String(length=256), nullable=True))
op.create_index("ix_feedback_source", "feedback", ["source"])
def downgrade() -> None:
with op.batch_alter_table("feedback") as batch_op:
batch_op.drop_index("ix_feedback_source")
batch_op.drop_column("admin_reply")
batch_op.drop_column("scene")
batch_op.drop_column("source")
-56
View File
@@ -1,56 +0,0 @@
"""add jd cps order fields
Revision ID: jd_cps_order_fields
Revises: 7db22acee504
Create Date: 2026-06-28 20:30:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "jd_cps_order_fields"
down_revision = "7db22acee504"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("cps_order") as batch_op:
batch_op.add_column(
sa.Column("platform", sa.String(length=20), nullable=False, server_default="meituan")
)
batch_op.add_column(sa.Column("external_order_id", sa.String(length=128), nullable=True))
batch_op.add_column(sa.Column("external_row_id", sa.String(length=128), nullable=True))
batch_op.add_column(sa.Column("estimated_commission_cents", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("actual_commission_cents", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("jd_valid_code", sa.String(length=16), nullable=True))
batch_op.add_column(sa.Column("settle_month", sa.String(length=16), nullable=True))
batch_op.add_column(sa.Column("site_id", sa.String(length=128), nullable=True))
batch_op.add_column(sa.Column("position_id", sa.String(length=128), nullable=True))
batch_op.add_column(sa.Column("pid", sa.String(length=128), nullable=True))
batch_op.add_column(sa.Column("sub_union_id", sa.String(length=128), nullable=True))
batch_op.create_index("ix_cps_order_platform", ["platform"])
batch_op.create_index("ix_cps_order_external_order_id", ["external_order_id"])
batch_op.create_index("ix_cps_order_external_row_id", ["external_row_id"])
batch_op.create_index("ix_cps_order_jd_valid_code", ["jd_valid_code"])
def downgrade() -> None:
with op.batch_alter_table("cps_order") as batch_op:
batch_op.drop_index("ix_cps_order_jd_valid_code")
batch_op.drop_index("ix_cps_order_external_row_id")
batch_op.drop_index("ix_cps_order_external_order_id")
batch_op.drop_index("ix_cps_order_platform")
batch_op.drop_column("sub_union_id")
batch_op.drop_column("pid")
batch_op.drop_column("position_id")
batch_op.drop_column("site_id")
batch_op.drop_column("settle_month")
batch_op.drop_column("jd_valid_code")
batch_op.drop_column("actual_commission_cents")
batch_op.drop_column("estimated_commission_cents")
batch_op.drop_column("external_row_id")
batch_op.drop_column("external_order_id")
batch_op.drop_column("platform")
-2
View File
@@ -21,7 +21,6 @@ from app.admin.routers.audit import router as audit_router
from app.admin.routers.auth import router as auth_router
from app.admin.routers.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
@@ -102,7 +101,6 @@ 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)
+1 -7
View File
@@ -136,16 +136,12 @@ 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
@@ -188,7 +184,6 @@ 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,
@@ -214,7 +209,6 @@ 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,
@@ -247,7 +241,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", "feed_all"):
if scene in (None, "feed", "draw"):
rows.extend(_feed_rows(db, date=date, user_id=user_id, scene=scene))
return rows
+37 -120
View File
@@ -3,11 +3,9 @@
只读。每行 = 一次广告事件(不再按用户聚合):
- **激励视频**:一次观看 = 1 条展示(ad_ecpm)+ 1 条发奖(ad_reward),按 ad_session_id 合并成一行,
直接给出 eCPM / 收益 + 状态 / 应发 / 实发 / 一致;点开看该条金币复算因子。
- **信息流(比价/领券)**:一次比价 / 一次领券 = 一条整场发奖(ad_feed_reward)一行,给出 eCPM /
发奖金币 + 应发 / 实发 / 一致;点开看金币复算因子。⚠️ draw 的逐条展示(ad_ecpm,impressionId 各自
独立、与整场发奖无公共键、无法归到「哪一次」)**不再单独占行**(2026-07 按「一次比价/领券放一块」调整)——
其展示数 / eCPM / 预估收益仍进全量统计(合计 / 趋势 / 分类大盘 / 穿山甲对照),只是主表不逐条铺开。
- 兜底:激励视频有展示无发奖(中途关 / 未达发奖)、有发奖无展示(未上报 eCPM)仍各自成行。
- **信息流**:轮播每条展示各一行(impressionId 各自独立);整场发奖(ad_feed_reward,client_event_id)
与逐条展示无法对应,单独成「纯发奖」行。
- 兜底:有展示无发奖(中途关 / 未达发奖)、有发奖无展示(未上报 eCPM)都各自成行。
展示与收益来自 ad_ecpm_record(收益 = eCPM元 ÷ 1000);应发 / 实发金币复用金币审计逐条复算
(ad_audit.audit_rows,与正式发奖同一公式口径,不另写公式)。合计与对账在全量上统计,
@@ -60,6 +58,14 @@ 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",
@@ -81,7 +87,6 @@ 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,
@@ -102,14 +107,9 @@ def ad_revenue_report(
# 同时保留全量列表,未被展示合并的成「纯发奖」事件。
reward_by_session: dict[tuple[int, str], list[dict]] = {}
all_reward_rows: list[dict] = []
# 报表 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
# 报表 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
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):
@@ -139,10 +139,7 @@ def ad_revenue_report(
)
if user_id is not None:
stmt = stmt.where(AdEcpmRecord.user_id == user_id)
if ad_type == "draw":
# draw = 所有信息流展示(业务已全 Draw,含历史误标 feed);展示行只进统计,不占主表行
stmt = stmt.where(AdEcpmRecord.ad_type.in_(["draw", "feed"]))
elif ad_type is not None:
if 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)
@@ -159,16 +156,10 @@ def ad_revenue_report(
"has_impression": True,
"impressions": 1,
"ecpm": rec.ecpm_raw,
# 单次展示收益(元)= 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,
),
# 单次展示收益(元)= eCPM元 ÷ 1000(每千次→单次);与发奖同源解析,口径一致。
"revenue_yuan": round(rewards.parse_ecpm_yuan(rec.ecpm_raw) / 1000.0, 6),
"adn": rec.adn,
"slot_id": rec.slot_id,
"sub_rewards": [],
"sub_count": 1,
}
if rwd is not None:
ev.update({
@@ -188,88 +179,33 @@ def ad_revenue_report(
})
events.append(ev)
# 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]] = {}
# 3) 未被展示合并的发奖行 → 「纯发奖」事件(信息流整场发奖 / 有发奖无展示)。
# 收益恒 0(收益只算展示侧,避免与展示行重复计)。
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"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,
"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,
"has_impression": False,
"impressions": 0,
"ecpm": avg_ecpm,
"ecpm": row["ecpm"],
"revenue_yuan": 0.0,
"row_revenue_yuan": row_revenue,
"adn": None,
"slot_id": None,
"has_reward": True,
"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),
"status": row["status"],
"expected_coin": int(row["expected_coin"]),
"actual_coin": int(row["actual_coin"]),
"matched": bool(row["matched"]),
"reward_detail": _reward_detail(row),
})
# 「场景」作为全局筛选(与 user_id/ad_type 一致):同时作用于明细、合计与 daily/hourly 趋势。
@@ -277,14 +213,6 @@ 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":
@@ -390,20 +318,9 @@ 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(main_rows),
"truncated": len(main_rows) > offset + limit,
"total": len(events),
"truncated": len(events) > offset + limit,
"total_impressions": total_impressions,
"total_revenue_yuan": total_revenue_yuan,
# 穿山甲后台收益合计(元):预估 revenue + 收益Api;非全量视图(带 user/类型/场景过滤)或无数据为 None。
@@ -417,5 +334,5 @@ def ad_revenue_report(
"hourly": hourly,
"type_stats": type_stats,
"dau": dau,
"items": main_rows[offset:offset + limit],
"items": events[offset:offset + limit],
}
-225
View File
@@ -1,225 +0,0 @@
"""admin「领券数据」看板聚合:发起/完成数、耗时均值与分位、按天/小时趋势、逐条明细。
数据源 coupon_session(一次领券一行,客户端 /api/v1/coupon/session 两段上报)。量级不大,全量拉
区间数据后 Python 聚合(分位 SQLite 无 percentile,统一 Python 算,PG 上也一致)。
- 发起数 = 区间内全部 session(含 started/completed/failed/abandoned),= 流失统计的基数。
- 完成数 / 耗时均值 / 分位 = 仅 status==completed 子集(成功跑完才有可比的"领券耗时")。
- summary/daily/hourly/total 在全量上算,不受分页;items 为排序后当前页。
"""
from __future__ import annotations
from datetime import UTC, date as _date, datetime
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from app.core import rewards
from app.models.coupon_state import CouponSession
from app.models.user import User
def _cn_hour(dt: datetime) -> int:
"""started_at(UTC 口径)→ 北京时间小时(023)。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)}
+1 -166
View File
@@ -7,14 +7,13 @@ 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 jd_union, meituan
from app.integrations import 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
@@ -25,11 +24,6 @@ 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))
@@ -53,36 +47,6 @@ 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):
@@ -285,80 +249,6 @@ def _map_order_fields(r: dict) -> dict:
}
def _jd_order_key(r: dict[str, Any]) -> str | None:
row_id = _text(_pick(r, "id", "rowId", "orderRowId"))
if row_id:
return f"jd:{row_id}"
order_id = _text(_pick(r, "orderId", "parentOrderId"))
sku_id = _text(_pick(r, "skuId"))
if order_id and sku_id:
return f"jd:{order_id}:{sku_id}"
if order_id:
return f"jd:{order_id}"
return None
def _map_jd_order_fields(r: dict[str, Any]) -> dict:
"""京东 order.row.query 单条订单行 → CpsOrder 字段。"""
sku_name = _text(_pick(r, "skuName", "goodsName", "productName"))
if sku_name and len(sku_name) > 500:
sku_name = sku_name[:500]
valid_code = _text(_pick(r, "validCode", "valid_code"))
actual_fee = _yuan_to_cents(_pick(r, "actualFee", "actual_fee"))
estimate_fee = _yuan_to_cents(_pick(r, "estimateFee", "estimate_fee"))
commission = actual_fee if actual_fee not in (None, 0) else estimate_fee
order_time = _jd_dt_to_utc(_pick(r, "orderTime", "order_time"))
return {
"platform": "jd",
"external_order_id": _text(_pick(r, "orderId", "parentOrderId")),
"external_row_id": _text(_pick(r, "id", "rowId", "orderRowId")),
"sid": _text(_pick(r, "subUnionId", "sub_union_id")),
"act_id": None,
"biz_line": None,
"trade_type": None,
"pay_price_cents": _yuan_to_cents(
_pick(r, "actualCosPrice", "estimateCosPrice", "price")
),
"commission_cents": commission,
"commission_rate": _text(_pick(r, "commissionRate", "commission_rate")),
"refund_price_cents": None,
"refund_profit_cents": None,
"estimated_commission_cents": estimate_fee,
"actual_commission_cents": actual_fee,
"mt_status": None,
"jd_valid_code": valid_code,
"invalid_reason": None if _is_jd_valid_code(valid_code) else f"validCode={valid_code}",
"product_name": sku_name,
"settle_month": _text(_pick(r, "payMonth", "settleMonth", "pay_month")),
"site_id": _text(_pick(r, "siteId", "site_id")),
"position_id": _text(_pick(r, "positionId", "position_id")),
"pid": _text(_pick(r, "pid")),
"sub_union_id": _text(_pick(r, "subUnionId", "sub_union_id")),
"pay_time": order_time,
"mt_update_time": _jd_dt_to_utc(_pick(r, "modifyTime", "updateTime", "modify_time"))
or order_time,
"raw": r,
}
def _is_jd_valid_code(valid_code: str | None) -> bool:
code = str(valid_code).strip() if valid_code is not None else ""
return bool(code and code not in _JD_INVALID_CODES and code not in _JD_UNPAID_CODES)
def is_jd_order_valid(order: CpsOrder) -> bool:
return _is_jd_valid_code(order.jd_valid_code)
def effective_commission_cents(order: CpsOrder) -> int:
if order.platform == "jd":
if order.actual_commission_cents not in (None, 0):
return order.actual_commission_cents or 0
if order.estimated_commission_cents is not None:
return order.estimated_commission_cents or 0
return order.commission_cents or 0
def reconcile_orders(
db: Session, *, start_time: int, end_time: int,
query_time_type: int = 1, sid: str | None = None, max_pages: int = 200,
@@ -384,11 +274,6 @@ 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()
@@ -406,56 +291,6 @@ 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,
+1 -4
View File
@@ -65,19 +65,16 @@ 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:
+7 -32
View File
@@ -390,7 +390,6 @@ 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,
@@ -410,9 +409,6 @@ 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:
@@ -538,7 +534,6 @@ 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,
@@ -548,15 +543,13 @@ def list_feedbacks(
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[Feedback], int | None, int]:
"""反馈工单列表。支持 状态 / 反馈类型(source) / 用户ID / 内容模糊 / 提交时间范围 筛选,
按 id·提交时间排序。**offset 分页**(cursor=offset):任意列排序下游标语义统一(同 [list_users]),
代价是翻页期间数据变动可能错位一条——admin 低频场景可接受。返回 (items, next_cursor, total),
total 供页码分页。created_at 为 timestamptz,日期入参统一转 tz-aware UTC 比较。"""
"""反馈工单列表。支持 状态 / 用户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():
@@ -795,7 +788,6 @@ 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(
@@ -813,12 +805,6 @@ def get_user_overview(db: Session, user_id: int) -> dict | None:
}
def _as_utc_naive(value: datetime) -> datetime:
"""窗口入参 → UTC naive(= _as_utc 去时区),与库里按 naive UTC 存取的 created_at 同口径比较。
历史遗留:_window_conds 一直引用本函数却未定义(自定义区间会 NameError),此处补上。"""
return _as_utc(value).replace(tzinfo=None)
def _window_conds(col, date_from: datetime | None, date_to: datetime | None) -> list:
"""把 [date_from, date_to] 转成对 col(created_at)的过滤条件;都为 None = 全量(注册至今)。"""
conds = []
@@ -908,13 +894,6 @@ def user_reward_stats(
}
def _cn_wall_to_utc(dt: datetime) -> datetime:
"""coin_transaction 存的是北京 wall-clock(naive,见 wallet.grant_coins「存北京 wall-clock」),转成 UTC naive,
与广告表(func.now() UTC)统一 —— 让本函数按同一绝对时刻排序、且前端 apiTime(把无时区时间当 UTC 再 +8 展示)
口径一致;否则签到会比实际多显示 8 小时(北京时间又被 +8)。"""
return dt.replace(tzinfo=rewards.CN_TZ).astimezone(timezone.utc).replace(tzinfo=None)
def user_coin_records(
db: Session,
user_id: int,
@@ -935,9 +914,6 @@ 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)
@@ -981,7 +957,7 @@ def user_coin_records(
.where(
CoinTransaction.user_id == user_id,
CoinTransaction.biz_type == "signin",
*_window_conds(CoinTransaction.created_at, signin_from, signin_to),
*_window_conds(CoinTransaction.created_at, date_from, date_to),
)
.order_by(CoinTransaction.created_at.desc())
.limit(fetch)
@@ -989,8 +965,7 @@ def user_coin_records(
rows.append({
"source": "signin",
"source_label": "签到",
# 北京 wall-clock → UTC,与广告记录统一(前端 apiTime 会 +8 回北京展示,不然签到会多 8 小时)
"created_at": _cn_wall_to_utc(rec.created_at),
"created_at": rec.created_at,
"ecpm": None,
"coin": rec.amount,
})
@@ -1016,7 +991,7 @@ def user_coin_records(
+ _count(
CoinTransaction, CoinTransaction.user_id == user_id,
CoinTransaction.biz_type == "signin",
*_window_conds(CoinTransaction.created_at, signin_from, signin_to),
*_window_conds(CoinTransaction.created_at, date_from, date_to),
)
)
return rows[offset:offset + limit], (offset + limit if has_more else None), total
+15 -94
View File
@@ -13,7 +13,6 @@ 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
@@ -36,13 +35,6 @@ REGULAR_TASK_EXCLUDED_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:
@@ -53,31 +45,14 @@ def _beijing_today_start_utc() -> datetime:
def today_dau(db: Session) -> int:
"""今日活跃用户数(DAU):登录 + 开始比价 + 开始领券,按用户去重
"""今日活跃用户数(DAU):北京时区今天 0 点后登录过(last_login_at)
广告收益报表复用这个函数;历史窗口 DAU 由 dashboard_overview 的 period 口径另算。
"""
today_bj = datetime.now(_BEIJING).date()
today_start = _beijing_today_start_utc()
tomorrow_start = today_start + timedelta(days=1)
login_user_ids = _id_set(
db,
select(User.id).where(User.last_login_at >= today_start, User.last_login_at < tomorrow_start),
return int(
db.execute(select(func.count(User.id)).where(User.last_login_at >= today_start)).scalar_one()
)
compare_start_user_ids = _event_user_ids(
db, (COMPARE_START_EVENT,), today_start, tomorrow_start
)
coupon_event_user_ids = _event_user_ids(
db, (COUPON_START_EVENT,), today_start, tomorrow_start
)
coupon_claim_user_ids = _id_set(
db,
select(CouponPromptEngagement.user_id).where(
CouponPromptEngagement.engage_date == today_bj,
CouponPromptEngagement.engage_type == "claim_started",
),
)
return len(login_user_ids | compare_start_user_ids | coupon_event_user_ids | coupon_claim_user_ids)
def _default_period_end() -> date:
@@ -116,24 +91,6 @@ def _date_range(date_from: date, date_to: date) -> list[date]:
return [date_from + timedelta(days=i) for i in range(days + 1)]
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:
@@ -150,11 +107,6 @@ def _commission_rate_percent(raw: str | None) -> Decimal | 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:
@@ -264,22 +216,17 @@ def dashboard_overview(
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
compare_user_ids = _user_id_set(
select(ComparisonRecord.user_id).where(*period_comparison_conds)
)
coupon_event_user_ids = _event_user_ids(
db, (COUPON_START_EVENT,), start_utc, end_utc
)
coupon_claim_user_ids = _user_id_set(
coupon_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_active_user_ids = login_user_ids | compare_user_ids | coupon_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)
@@ -301,13 +248,10 @@ def dashboard_overview(
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_compare_user_ids = _user_id_set(
select(ComparisonRecord.user_id).where(*daily_comparison_conds)
)
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(
daily_coupon_user_ids = _user_id_set(
select(CouponPromptEngagement.user_id).where(
CouponPromptEngagement.engage_date == cur_date,
CouponPromptEngagement.engage_type == "claim_started",
@@ -317,10 +261,7 @@ def dashboard_overview(
{
"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
daily_login_user_ids | daily_compare_user_ids | daily_coupon_user_ids
),
"new_users": _count(
User,
@@ -376,7 +317,7 @@ def dashboard_overview(
*period_coin_conds,
CoinTransaction.biz_type.notin_(REGULAR_TASK_EXCLUDED_BIZ_TYPES),
)
period_cps_orders = list(
period_meituan_orders = list(
db.execute(
select(CpsOrder).where(
CpsOrder.pay_time >= start_utc,
@@ -384,17 +325,9 @@ def dashboard_overview(
)
).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
@@ -479,8 +412,8 @@ def dashboard_overview(
"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),按用户去重"
"近似口径:登录(last_login_at)+已上报比价记录+领券claim_started;"
"尚不包含未完成上报的比价开始事件"
),
},
"comparison": {
@@ -517,7 +450,7 @@ def dashboard_overview(
},
"cps": {
"available": True,
"note": "美团/JD CPS 读 cps_order 对账订单;淘宝佣金暂空",
"note": "美团 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
@@ -526,17 +459,5 @@ def dashboard_overview(
"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),
},
}
+1 -8
View File
@@ -56,13 +56,6 @@ 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",
@@ -82,7 +75,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, app_env=app_env,
user_id=user_id, ad_type=ad_type, feed_scene=feed_scene,
granularity=granularity, limit=limit, offset=offset, sort=sort,
)
return AdRevenueReportOut(
-106
View File
@@ -1,106 +0,0 @@
"""admin「领券数据」看板:发起/完成数 + 领券耗时(均值 + P5/P50/P95/P99)+ 按天趋势 + 逐条明细。
任意已登录 admin 可看(只读)。聚合逻辑在 app/admin/repositories/coupon_data.py。
数据源 coupon_session(客户端 /api/v1/coupon/session 两段上报)。
"""
from __future__ import annotations
from datetime import date as _date
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from app.admin.deps import AdminDb, get_current_admin
from app.admin.repositories import coupon_data
from app.admin.schemas.coupon_data import (
CouponDataDaily,
CouponDataHourly,
CouponDataOut,
CouponDataRow,
CouponDataSummary,
CouponUserRecordsOut,
)
from app.core.rewards import cn_today
router = APIRouter(
prefix="/admin/api/coupon-data",
tags=["admin-coupon-data"],
dependencies=[Depends(get_current_admin)],
)
# 区间最大跨度(天);超出拒绝,避免一次拉过多天拖垮接口(对齐广告收益报表)。
_MAX_RANGE_DAYS = 92
def _parse_day(value: str | None, *, field: str, default: _date) -> _date:
if value is None:
return default
try:
return _date.fromisoformat(value)
except ValueError as e:
raise HTTPException(status_code=422, detail=f"{field} 需为 YYYY-MM-DD") from e
@router.get(
"",
response_model=CouponDataOut,
summary="领券数据看板(发起/完成数 + 耗时分位 + 按天趋势 + 逐条明细)",
)
def get_coupon_data(
db: AdminDb,
date_from: Annotated[str | None, Query(description="起始日 北京 YYYY-MM-DD,默认今天")] = None,
date_to: Annotated[str | None, Query(description="结束日 北京 YYYY-MM-DD,闭区间,默认=date_from")] = None,
user: Annotated[str | None, Query(description="用户手机号/昵称模糊搜;不传=全部")] = None,
app_env: Annotated[str, Query(description="prod(默认) / dev / all(全部环境)")] = "prod",
granularity: Annotated[
str, Query(description="day=按天 / hour=按小时(北京);区间>1 天建议 day")
] = "day",
limit: Annotated[int, Query(ge=1, le=1000, description="每页条数(分页大小)")] = 500,
offset: Annotated[int, Query(ge=0, description="分页偏移(已跳过条数)=(页码-1)×每页条数")] = 0,
sort: Annotated[
str, Query(description="排序:time=发起时间倒序(默认) / elapsed=耗时倒序")
] = "time",
) -> CouponDataOut:
today = cn_today()
d_from = _parse_day(date_from, field="date_from", default=today)
d_to = _parse_day(date_to, field="date_to", default=d_from)
if d_to < d_from:
raise HTTPException(status_code=422, detail="date_to 不能早于 date_from")
if (d_to - d_from).days + 1 > _MAX_RANGE_DAYS:
raise HTTPException(status_code=422, detail=f"区间最长 {_MAX_RANGE_DAYS}")
# 报表默认只看 prod(对齐广告报表防串台口径);app_env=all 时不过滤、看全部环境。
env = None if app_env == "all" else app_env
result = coupon_data.coupon_data_report(
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(),
user=user, app_env=env, granularity=granularity,
limit=limit, offset=offset, sort=sort,
)
return CouponDataOut(
date_from=d_from.isoformat(),
date_to=d_to.isoformat(),
summary=CouponDataSummary(**result["summary"]),
daily=[CouponDataDaily(**d) for d in result["daily"]],
hourly=[CouponDataHourly(**h) for h in result["hourly"]],
total=result["total"],
items=[CouponDataRow(**r) for r in result["items"]],
)
@router.get(
"/user-records",
response_model=CouponUserRecordsOut,
summary="某用户全部领券记录(点手机号抽屉:领券次数 + 记录列表)",
)
def get_user_coupon_records(
db: AdminDb,
user_id: Annotated[int, Query(description="用户 id")],
limit: Annotated[int, Query(ge=1, le=500, description="最多返回条数")] = 100,
sort_by: Annotated[str, Query(description="兼容 UserRecordsDrawer 参数;固定按发起时间倒序")] = "created_at",
sort_order: Annotated[str, Query(description="兼容参数,忽略")] = "desc",
) -> CouponUserRecordsOut:
result = coupon_data.coupon_user_records(db, user_id=user_id, limit=limit)
return CouponUserRecordsOut(
items=[CouponDataRow(**r) for r in result["items"]],
total=result["total"],
)
+16 -48
View File
@@ -1,7 +1,7 @@
"""admin CPS 分发与对账:群/活动管理 + 生成落地页短链 + 联盟订单对账 + 统计。
"""admin CPS 分发与对账:群/活动管理 + 生成落地页短链 + 美团订单对账 + 统计。
平台:meituan(actId+sid 转链 + query_order 对账) / taobao(整段淘口令) / jd(链接)。
淘宝暂未接 API → 只统计点击(咱落地页 PV/UV + 淘宝复制),对账字段显示 "-"
淘宝/京东无 API → 只统计点击(咱落地页 PV/UV + 淘宝复制),对账字段显示 "-"
群/活动管理 = operator;订单对账(涉佣金) = finance;只读列表/统计 = 登录即可。
"""
from __future__ import annotations
@@ -34,7 +34,6 @@ 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
@@ -374,22 +373,7 @@ def _reconcile_range_to_ts(
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="拉取联盟订单对账")
@router.post("/orders/reconcile", response_model=CpsReconcileResult, summary="拉取美团订单对账")
def reconcile_orders(
request: Request,
admin: Annotated[AdminUser, Depends(require_role("finance"))],
@@ -398,42 +382,26 @@ def reconcile_orders(
date_to: Annotated[str | None, Query(description="结束日 YYYY-MM-DD")] = None,
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",
query_time_type: Annotated[int, Query(ge=1, le=2)] = 1,
) -> CpsReconcileResult:
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}
start_ts, end_ts = _reconcile_range_to_ts(
_parse_day(date_from, field="date_from"),
_parse_day(date_to, field="date_to"),
days,
)
try:
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)
result = cps_repo.reconcile_orders(
db,
start_time=start_ts,
end_time=end_ts,
query_time_type=query_time_type,
sid=sid,
)
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={
"platform": platform,
"date_from": date_from,
"date_to": date_to,
"days": days,
-7
View File
@@ -36,8 +36,6 @@ 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,
@@ -50,7 +48,6 @@ 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,
@@ -104,7 +101,6 @@ 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,
)
@@ -127,7 +123,6 @@ def approve_feedback(
"after": "adopted",
"reward_coins": payload.reward_coins,
"note": payload.note,
"reply": payload.reply,
},
ip=get_client_ip(request),
commit=False,
@@ -157,7 +152,6 @@ 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,
)
@@ -172,7 +166,6 @@ def reject_feedback(
"after": "rejected",
"reason": payload.reason,
"note": payload.note,
"reply": payload.reply,
},
ip=get_client_ip(request),
commit=False,
+11 -19
View File
@@ -210,28 +210,22 @@ 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="用户不存在")
# 按目标账户选「余额字段 + 变动入口」(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 写一笔流水(沿用原子/审计/扣负保护)
# set=设为目标值:读当前余额算差值,仍复用 grant_cash 写一笔流水(沿用原子/审计/扣负保护)
if body.mode == "set":
if body.amount_cents < 0:
raise HTTPException(status_code=400, detail=f"目标{acct_label}值不能为负")
raise HTTPException(status_code=400, detail="目标现金值不能为负")
# lock=True:锁账户行,防连点/并发各读同一 before 算同一 delta 双写,余额错位
acc_locked = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True)
before = getattr(acc_locked, balance_attr)
before = wallet_repo.get_or_create_account(
db, user_id, commit=False, lock=True
).cash_balance_cents
delta = body.amount_cents - before
if delta == 0:
raise HTTPException(
status_code=400, detail=f"当前{acct_label}已为 {body.amount_cents} 分,无需调整"
status_code=400, detail=f"当前现金已为 {body.amount_cents} 分,无需调整"
)
else:
if body.amount_cents == 0:
@@ -240,20 +234,18 @@ 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 getattr(acc_now, balance_attr) + delta < 0:
if acc_now.cash_balance_cents + delta < 0:
raise HTTPException(
status_code=400,
detail=f"扣减后{acct_label}为负(当前余额 {getattr(acc_now, balance_attr)} 分)",
status_code=400, detail=f"扣减后现金为负(当前余额 {acc_now.cash_balance_cents} 分)"
)
biz_type = "admin_grant" if delta > 0 else "admin_deduct"
# grant 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕)
acc, _ = grant_fn(
# grant_cash 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕)
acc, _ = wallet_repo.grant_cash(
db, user_id, delta, biz_type=biz_type, remark=f"admin:{body.reason}"[:128],
)
detail = {
"account": body.account,
"amount_cents": delta,
"balance_after_cents": getattr(acc, balance_attr),
"balance_after_cents": acc.cash_balance_cents,
"reason": body.reason,
}
if body.mode == "set":
-3
View File
@@ -50,8 +50,6 @@ 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,
@@ -71,7 +69,6 @@ def list_withdraws(
db,
user_id=user_id,
status=status,
source=source,
keyword=keyword,
date_from=date_from,
date_to=date_to,
-14
View File
@@ -94,11 +94,6 @@ 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);纯发奖行为空")
# ── 发奖侧 ──
@@ -111,15 +106,6 @@ 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):
-83
View File
@@ -1,83 +0,0 @@
"""admin「领券数据」看板 schemas:汇总卡 + 按天/小时趋势 + 逐条领券明细。
数据源 coupon_session(一次领券一行)耗时单位 ms(前端按需折秒);均值/分位只统计 completed
"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class CouponDataSummary(BaseModel):
"""汇总卡:发起/完成数 + 耗时均值与分位(P5/P50/P95/P99,基于 completed 的 elapsed_ms)。"""
started_count: int = Field(..., description="发起数(区间内所有领券 session)")
completed_count: int = Field(..., description="完成数(status=completed)")
avg_elapsed_ms: int | None = Field(None, description="平均耗时(ms,仅 completed;无数据为空)")
p5_ms: int | None = Field(None, description="耗时 5 分位(ms)")
p50_ms: int | None = Field(None, description="耗时 50 分位(ms,中位数)")
p95_ms: int | None = Field(None, description="耗时 95 分位(ms)")
p99_ms: int | None = Field(None, description="耗时 99 分位(ms)")
class CouponDataDaily(BaseModel):
"""按天趋势(全量,不受分页影响):柱=发起/完成数,线=平均耗时。"""
date: str = Field(..., description="北京时间 YYYY-MM-DD")
started_count: int
completed_count: int
avg_elapsed_ms: int | None = Field(None, description="当天平均耗时(ms,仅 completed)")
class CouponDataHourly(BaseModel):
"""按北京小时(023)趋势(单日 granularity=hour 时非空)。"""
hour: int = Field(..., description="北京时间小时 023")
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
+2 -9
View File
@@ -1,7 +1,7 @@
"""admin CPS 分发与对账 schemas。金额统一「分」(cents),前端 yuan() 展示。
平台:meituan(actId+sid 转链对账) / taobao(淘口令,只统计点击) / jd(链接 + 订单 API 对账)
对账类字段对淘宝为 None 前端显示 "-"(暂未对账)
平台:meituan(actId+sid 转链对账) / taobao(淘口令,只统计点击) / jd(链接,只统计点击)
对账类字段对淘宝/京东 None 前端显示 "-"(无法对账)
"""
from __future__ import annotations
@@ -116,22 +116,15 @@ 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
-5
View File
@@ -103,11 +103,6 @@ class DashboardCps(BaseModel):
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):
+1 -9
View File
@@ -15,17 +15,11 @@ 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
@@ -45,14 +39,12 @@ class FeedbackApproveRequest(BaseModel):
le=FEEDBACK_REWARD_MAX_COINS,
description="采纳后发放金币数",
)
note: str | None = Field(default=None, max_length=256, description="采纳要点/审核备注(内部)")
reply: str | None = Field(default=None, max_length=256, description="给用户的回复留言,用户端可见")
note: 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):
-7
View File
@@ -17,7 +17,6 @@ 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
@@ -30,7 +29,6 @@ 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
@@ -90,11 +88,6 @@ 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)"
)
-2
View File
@@ -41,8 +41,6 @@ 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
-29
View File
@@ -29,7 +29,6 @@ from app.schemas.coupon_state import (
CouponPromptDismissIn,
CouponPromptShouldShowOut,
CouponPromptShownIn,
CouponSessionIn,
CouponStatsOut,
)
@@ -195,34 +194,6 @@ 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** 不再自动弹。
+1 -24
View File
@@ -31,18 +31,7 @@ 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:
@@ -57,12 +46,10 @@ 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,
)
@@ -74,11 +61,6 @@ 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=""),
@@ -94,8 +76,6 @@ 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:
@@ -111,13 +91,10 @@ 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)
-3
View File
@@ -202,9 +202,6 @@ 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(
+1 -16
View File
@@ -68,7 +68,7 @@ class Settings(BaseSettings):
# 无障碍保护存活监控后台任务(pull 后置检测;本期不接推送)
HEARTBEAT_MONITOR_ENABLED: bool = True # 总开关
HEARTBEAT_TIMEOUT_MINUTES: int = 60 # 多久没心跳算掉线(1 小时,避免短暂离线误判被杀)
HEARTBEAT_TIMEOUT_MINUTES: int = 10 # 多久没心跳算掉线(≈3 个客户端心跳周期)
HEARTBEAT_SCAN_INTERVAL_SEC: int = 60 # 扫描周期
# ===== 短信 =====
@@ -113,21 +113,6 @@ 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 移动应用,用于微信支付);这是【已认证服务号】。
-163
View File
@@ -1,163 +0,0 @@
"""京东联盟 OpenAPI 客户端。
当前只接数据大盘需要的订单明细接口:
`jd.union.open.order.row.query`京东要求订单查询时间窗最长 1 小时,
调用方负责切窗分页
"""
from __future__ import annotations
import hashlib
import json
import logging
from datetime import datetime, timedelta, timezone
from typing import Any
import httpx
from app.core.config import settings
logger = logging.getLogger(__name__)
_BEIJING = timezone(timedelta(hours=8))
class JdUnionError(RuntimeError):
"""京东联盟 API 调用失败。"""
def _parse_json_maybe(value: Any) -> Any:
if not isinstance(value, str):
return value
text = value.strip()
if not text:
return value
try:
return json.loads(text)
except json.JSONDecodeError:
return value
def _sign(params: dict[str, Any], secret: str) -> str:
pieces = [secret]
for key in sorted(k for k in params if k != "sign"):
value = params[key]
if value is None:
continue
pieces.append(f"{key}{value}")
pieces.append(secret)
raw = "".join(pieces)
return hashlib.md5(raw.encode("utf-8")).hexdigest().upper()
def _unwrap_response(data: dict[str, Any]) -> dict[str, Any]:
if "error_response" in data:
err = data["error_response"] or {}
msg = err.get("zh_desc") or err.get("en_desc") or err.get("msg") or err
raise JdUnionError(f"京东 API 错误: {msg}")
body: Any = data
for key, value in data.items():
if key.endswith("_responce") or key.endswith("_response"):
body = value
break
body = _parse_json_maybe(body)
if not isinstance(body, dict):
raise JdUnionError("京东 API 返回格式异常")
result = body.get("queryResult", body.get("result", body))
result = _parse_json_maybe(result)
if not isinstance(result, dict):
raise JdUnionError("京东 API 业务结果格式异常")
code = str(result.get("code", result.get("resultCode", "200")))
if code not in {"0", "200"}:
msg = result.get("message") or result.get("msg") or result.get("resultMsg") or result
raise JdUnionError(f"京东 API 业务错误: {msg}")
return result
def call(method: str, payload: dict[str, Any], *, version: str = "1.0") -> dict[str, Any]:
if not settings.jd_union_configured:
raise JdUnionError("京东联盟凭证未配置")
biz_json = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
params: dict[str, Any] = {
"method": method,
"app_key": settings.JD_UNION_APP_KEY,
"timestamp": datetime.now(_BEIJING).strftime("%Y-%m-%d %H:%M:%S"),
"format": "json",
"v": version,
"sign_method": "md5",
"360buy_param_json": biz_json,
}
params["sign"] = _sign(params, settings.JD_UNION_APP_SECRET)
try:
with httpx.Client(timeout=settings.JD_UNION_TIMEOUT_SEC, trust_env=False) as client:
resp = client.post(settings.JD_UNION_GATEWAY, data=params)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPError as e:
raise JdUnionError(f"京东 API 网络错误: {e}") from e
except json.JSONDecodeError as e:
raise JdUnionError("京东 API 返回非 JSON") from e
return _unwrap_response(data)
def _extract_rows(result: dict[str, Any]) -> tuple[list[dict[str, Any]], bool]:
payload = _parse_json_maybe(result.get("data", result.get("result", result)))
has_more = bool(result.get("hasMore") or result.get("has_more"))
if isinstance(payload, dict):
for key in ("orderRowResp", "orderRows", "orderList", "orders", "list", "rows"):
rows = _parse_json_maybe(payload.get(key))
if isinstance(rows, list):
return [r for r in rows if isinstance(r, dict)], bool(
payload.get("hasMore") or payload.get("has_more") or has_more
)
return [], bool(payload.get("hasMore") or payload.get("has_more") or has_more)
if isinstance(payload, list):
return [r for r in payload if isinstance(r, dict)], has_more
return [], has_more
def query_order_rows(
*,
start_time: datetime,
end_time: datetime,
query_time_type: int = 3,
page_index: int = 1,
page_size: int = 200,
) -> dict[str, Any]:
"""查询京东 CPS 订单行。
query_time_type: 1 下单时间, 2 完成时间, 3 更新时间
start_time/end_time 用北京时间展示给京东;调用方需保证窗口不超过 1 小时
"""
start_bj = start_time.astimezone(_BEIJING)
end_bj = end_time.astimezone(_BEIJING)
if end_bj <= start_bj:
return {"rows": [], "has_more": False}
if end_bj - start_bj > timedelta(hours=1):
raise JdUnionError("京东订单查询单次时间窗不能超过 1 小时")
order_req: dict[str, Any] = {
"pageIndex": page_index,
"pageSize": min(max(page_size, 1), 200),
"type": query_time_type,
"startTime": start_bj.strftime("%Y-%m-%d %H:%M:%S"),
"endTime": end_bj.strftime("%Y-%m-%d %H:%M:%S"),
}
if settings.JD_UNION_AUTH_KEY:
order_req["key"] = settings.JD_UNION_AUTH_KEY
result = call("jd.union.open.order.row.query", {"orderReq": order_req})
rows, has_more = _extract_rows(result)
logger.info(
"jd.union.open.order.row.query fetched rows=%s page=%s has_more=%s",
len(rows),
page_index,
has_more,
)
return {"rows": rows, "has_more": has_more, "raw": result}
+10 -35
View File
@@ -2,23 +2,18 @@
通过 `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
@@ -26,8 +21,12 @@ from app.api.v1.compare import router as compare_router
from app.api.v1.compare_milestone import router as compare_milestone_router
from app.api.v1.compare_record import router as compare_record_router
from app.api.v1.coupon import router as coupon_router
from app.api.v1.cps_redirect import router as cps_redirect_router
from app.api.v1.device import router as device_router
from app.api.v1.cps_redirect import router as cps_redirect_router
from app.api.internal.app_version import router as internal_app_version_router
from app.api.internal.launch_confirm import router as internal_launch_confirm_router
from app.api.internal.price import router as internal_price_router
from app.api.internal.store import router as internal_store_router
from app.api.v1.feedback import router as feedback_router
from app.api.v1.invite import router as invite_router
from app.api.v1.meituan import router as meituan_router
@@ -41,14 +40,14 @@ from app.api.v1.user import router as user_router
from app.api.v1.wallet import router as wallet_router
from app.api.v1.wxpay import router as wxpay_router
from app.core.config import settings
from app.core.daily_exchange_worker import (
start_daily_exchange_worker,
stop_daily_exchange_worker,
)
from app.core.heartbeat_monitor_worker import (
start_heartbeat_monitor,
stop_heartbeat_monitor,
)
from app.core.daily_exchange_worker import (
start_daily_exchange_worker,
stop_daily_exchange_worker,
)
from app.core.logging import setup_logging
from app.core.pricebot_client import aclose_pricebot_client, get_pricebot_client
from app.core.withdraw_reconcile_worker import (
@@ -138,30 +137,6 @@ 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
View File
@@ -19,7 +19,6 @@ 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
-76
View File
@@ -181,79 +181,3 @@ 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}>"
)
+4 -22
View File
@@ -1,13 +1,11 @@
"""CPS 对账订单(cps_order)。
从联盟 API 按时间窗拉回平台落库的 CPS 订单明细字段最初对齐美团 query_order,
后续兼容京东订单报表:
美团联盟 query_order 按时间窗拉回 sid 归群的订单明细字段对齐 query_order
实测返回:
- payPrice / profit 字符串 入库统一转(与全站口径一致)
- payTime / updateTime 是秒级时间戳 入库转 tz-aware datetime
- status: 2付款 3完成 4取消 5风控 6结算(取消/风控不计佣金)
order_id 全局唯一,reconcile 按它 upsert(订单状态会变,重复拉则更新)京东订单用
`jd:<row_id>` 前缀避免与美团订单号碰撞
order_id 全局唯一,reconcile 按它 upsert(订单状态会变,重复拉则更新)
"""
from __future__ import annotations
@@ -24,13 +22,8 @@ class CpsOrder(Base):
__tablename__ = "cps_order"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 平台:meituan / jd。历史数据迁移默认 meituan
platform: Mapped[str] = mapped_column(String(20), default="meituan", index=True, nullable=False)
# 平台订单号/行号包装后的全局唯一键,upsert 幂等。
# 美团订单号(加密串),全局唯一,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)
@@ -42,21 +35,11 @@ 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
@@ -76,6 +59,5 @@ class CpsOrder(Base):
def __repr__(self) -> str: # pragma: no cover
return (
f"<CpsOrder id={self.id} order_id={self.order_id!r} "
f"platform={self.platform!r} sid={self.sid!r} "
f"status={self.mt_status or self.jd_valid_code} profit_cents={self.commission_cents}>"
f"sid={self.sid!r} status={self.mt_status} profit_cents={self.commission_cents}>"
)
-11
View File
@@ -23,14 +23,6 @@ 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)
@@ -44,9 +36,6 @@ class Feedback(Base):
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
)
+1 -90
View File
@@ -6,7 +6,7 @@
from __future__ import annotations
import logging
from datetime import date, datetime, timezone
from datetime import date, datetime
from zoneinfo import ZoneInfo
from sqlalchemy import delete, func, select
@@ -17,7 +17,6 @@ from app.models.coupon_state import (
CouponClaimRecord,
CouponDailyCompletion,
CouponPromptEngagement,
CouponSession,
)
logger = logging.getLogger("shagua.coupon_state")
@@ -233,91 +232,3 @@ 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()
-4
View File
@@ -17,8 +17,6 @@ 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,
@@ -28,8 +26,6 @@ def create_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,
+1 -9
View File
@@ -216,21 +216,13 @@ def try_reward_on_compare(db: Session, invitee_user_id: int) -> CompareRewardRes
def get_stats(db: Session, inviter_id: int) -> tuple[int, int]:
"""返回 (已成功邀请人数, 累计从邀请获得的金币)。
"已邀请好友数"口径 = 完成一次比价(已触发邀请奖励金)的被邀请人数, 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,
InviteRelation.compare_reward_granted.is_(True),
)
.where(InviteRelation.inviter_user_id == inviter_id)
).scalar_one()
coins = db.execute(
select(func.coalesce(func.sum(InviteRelation.inviter_coin), 0))
+1 -7
View File
@@ -610,7 +610,6 @@ 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(待人工审核),**不打款**。
@@ -620,13 +619,8 @@ 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
"""
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):
if amount_cents < rewards.get_withdraw_min_cents(db) or amount_cents > rewards.get_withdraw_max_cents(db):
raise InvalidWithdrawAmountError
# 提现即要求已绑微信:否则审核通过也打不了款,提前拦更友好
-25
View File
@@ -49,28 +49,3 @@ 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
-4
View File
@@ -30,14 +30,10 @@ 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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 847 KiB

+4 -13
View File
@@ -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,24 +273,15 @@
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", handleWebsiteDownload);
document.getElementById("dlbtn2").addEventListener("click", handleDownload);
</script>
</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

-67
View File
@@ -1,67 +0,0 @@
# 穿山甲 GroMore 收益拉取 定时任务 — 运维手册
> 对象:维护「每天拉穿山甲后台收益入库」这套定时任务的同事。
> 🔒 服务器登录信息见**私密交接清单**,不入库。
## 它是什么
admin「广告收益报表」里的「穿山甲后台收益(T+1)」读的是**本地表 `ad_pangle_daily_revenue` 的快照,不是实时查穿山甲**。穿山甲只通过 GroMore 数据 API 给数、且 **T+1**(次日约 10:00 出昨天的数),所以每天得拉一次入库,报表才会往前走。
- 每天 10:30 跑一轮 `scripts/sync_pangle_revenue.py`,默认 `--days 3` 回补近 3 天。
- 维度 = 日期 × 应用(site_id)× 广告位(ad_unit_id);指标 = `revenue`(预估)+ `api_revenue`(结算口径)。
- **幂等 upsert**:同一(日期×应用×代码位)重跑只覆盖、不重复,故回补 / 重跑 / catch-up 都安全。
- 穿山甲无用户/设备维度 → 只能落「汇总/趋势级」,报表带 user_id 过滤时这块收益置空(显示「-」)。
## 文件
| 项 | 路径 |
|---|---|
| 脚本入口 | `scripts/sync_pangle_revenue.py` |
| 拉取 / 签名 | `app/integrations/pangle_report.py`(签名=参数字典序拼接+secure_key 后 MD5) |
| 入库表 | `ad_pangle_daily_revenue`(读写在 `app/repositories/ad_pangle_revenue.py`) |
| 凭证 | `.env``PANGLE_REPORT_USER_ID` / `PANGLE_REPORT_ROLE_ID` / `PANGLE_REPORT_SECURITY_KEY` |
| 应用映射 | `.env``PANGLE_REPORT_SITE_ID_PROD`(5830519)/ `PANGLE_REPORT_SITE_ID_TEST`(5832303) |
| systemd 单元 | `deploy/pangle-revenue.{service,timer}` |
## 上线前置(只做一次)
1. **填凭证**:后台「接入中心 → GroMore-API」领 user_id / role_id / Security Key(`secure_key`,**≠ 发奖 m-key**),填进线上 `.env``PANGLE_REPORT_*`。三项填齐脚本才工作,缺任一 → no-op 退出。
2. **子账号要授权**:线上若用子账号(role_id ≠ user_id),需主账号给它授「查看全部数据」,否则接口报 **118**(无权限)。本人自查自己(user_id=role_id)无此问题。
3. 凭证泄露:后台「重置 key 值」即可,改完同步线上 `.env` 重跑。
## 部署(Linux 服务器,需 root)
```bash
sudo cp deploy/pangle-revenue.{service,timer} /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now pangle-revenue.timer
systemctl list-timers pangle-revenue.timer # 确认下次触发时间(应是次日 10:30)
```
## 怎么看健康 / 手动跑一次
```bash
sudo systemctl start pangle-revenue.service # 立即手动跑一轮(不等 10:30)
journalctl -u pangle-revenue -n 30 --no-pager # 看日志:拉取区间 / 入库行数 / 新增更新 / 预估收益合计
```
成功日志形如:`✅ 完成:接口 N 行 → 入库 M 行(跳过 x),新增 a / 更新 b;预估收益合计 ¥19.42`
> 看不到收益、提示 `PANGLE_REPORT_* 未配置`→ 回「上线前置」补 `.env`;报 118 → 子账号没授「查看全部数据」。
## 本机 Windows 开发(无 systemd)
直接手动跑:
```
.venv\Scripts\python -m scripts.sync_pangle_revenue # 拉昨天
.venv\Scripts\python -m scripts.sync_pangle_revenue --days 3 # 回补近 3 天
.venv\Scripts\python -m scripts.sync_pangle_revenue --date 2026-06-27 # 指定单天
.venv\Scripts\python -m scripts.sync_pangle_revenue --start 2026-06-01 --end 2026-06-27 # 区间回补
```
## 脚本参数
- 无参:拉**昨天**(北京时间)。timer 用的是 `--days 3`
- `--days N`:从昨天起往前回补 N 天(含昨天)。
- `--date YYYY-MM-DD`:指定单天。
- `--start / --end`:指定闭区间(跨度 ≤ 31 天,接口上限 1 个月,超了报 114)。
## 注意事项
- **触发时间**:`OnCalendar=*-*-* 10:30:00`。穿山甲 ~10:00 出数,故别早于 10:00 跑(会拉到空/不全)。
- **catch-up**:`Persistent=true` 补跑错过的那一轮;叠加 `--days 3`,漏一两天重新触发即自愈。
- **今天 / 今天以前要分开查**:脚本默认只拉昨天及更早,不混查今天(接口约束),无需关心。
- **join key 是 `ad_unit_id`(我们配的 104xxx)不是 `code_id`**:`code_id` 是底层各 ADN 代码位,对不上口径;`ad_unit_id='-1'` 是未归因桶。改维度时务必注意(详见脚本头注释)。
- **`api_revenue` 很稀疏**:测试应用 ADN 没配 Reporting → 全 0,仅 prod 个别位有;`revenue`(预估)才是稳的主力。
- **DB 无关**:sqlite / postgres 均可(upsert 逐行 select-then-write,不像美团 ETL 需要 PG)。
- **别和别的触发方式双跑**:本 systemd timer 与「手动 cron / 进程内任务」二选一,虽幂等不会重复入库,纯属多余。
- **改脚本 / 改部署**:走 git + PR,由有 root 的人部署。
-38
View File
@@ -1,38 +0,0 @@
# 每天拉穿山甲 GroMore T+1 天级收益入库 —— 单轮跑,由 pangle-revenue.timer 每天 10:30 触发。
# 落 ad_pangle_daily_revenue 表,供 admin 广告收益报表的「穿山甲后台收益(T+1)」区块。
#
# 仅用于 Linux 服务器;本机 Windows 开发无 systemd,直接手动跑脚本即可:
# .venv\Scripts\python -m scripts.sync_pangle_revenue # 拉昨天(北京时间)
# .venv\Scripts\python -m scripts.sync_pangle_revenue --days 3 # 回补近 3 天
#
# 部署(服务器):
# sudo cp deploy/pangle-revenue.{service,timer} /etc/systemd/system/
# sudo systemctl daemon-reload && sudo systemctl enable --now pangle-revenue.timer
# # 手动跑一次验证: sudo systemctl start pangle-revenue.service && journalctl -u pangle-revenue -n 30
#
# 前置:.env 配好 PANGLE_REPORT_USER_ID / PANGLE_REPORT_ROLE_ID / PANGLE_REPORT_SECURITY_KEY
# (后台「接入中心 → GroMore-API」领;子账号需主账号授「查看全部数据」否则接口 118)。
# 未配齐这三项 → 脚本自动 no-op 退出,免动 timer。
[Unit]
Description=Sync Pangle GroMore daily revenue (T+1, one-shot, driven by timer)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=root
WorkingDirectory=/opt/shaguabijia-app-server
Environment="PATH=/opt/shaguabijia-app-server/.venv/bin:/usr/bin:/bin"
EnvironmentFile=/opt/shaguabijia-app-server/.env
# 默认拉昨天;--days 3 回补近 3 天(幂等 upsert,应对偶发漏跑 + 穿山甲对历史数据订正,重跑无害)。
ExecStart=/opt/shaguabijia-app-server/.venv/bin/python -m scripts.sync_pangle_revenue --days 3
SyslogIdentifier=pangle-revenue
# 仅几个 HTTP 请求 + 小批量入库,通常数秒;给 10min 硬超时防穿山甲接口卡死。
TimeoutStartSec=600
# 与主服务 shaguabijia-app-server.service 同款加固。
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/opt/shaguabijia-app-server
ProtectHome=true
-14
View File
@@ -1,14 +0,0 @@
# 每天 10:30 触发一次穿山甲 GroMore T+1 收益拉取入库(Linux 服务器用)。
# 见 pangle-revenue.service 顶部注释的部署步骤。
[Unit]
Description=Run Pangle GroMore daily revenue sync at 10:30
[Timer]
# 穿山甲 T+1、次日约 10:00 出数;10:30 触发留 30min 余量。要错开整点扎堆可微调到 10:35。
OnCalendar=*-*-* 10:30:00
# 服务器宕机/重启后,补跑错过的那一轮(而不是干等次日);叠加 --days 3 回补,漏一两天能自愈。
Persistent=true
AccuracySec=1min
[Install]
WantedBy=timers.target
-45
View File
@@ -1,45 +0,0 @@
#!/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/ 同步到生产服务器(本脚本不负责上线)。"
-62
View File
@@ -66,7 +66,6 @@ def test_dashboard_overview(admin_client: TestClient, admin_token: str) -> None:
assert "success_rate" in data["comparison"]
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:
@@ -190,67 +189,6 @@ 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:
-29
View File
@@ -368,35 +368,6 @@ 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:
-71
View File
@@ -11,7 +11,6 @@ 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
@@ -181,73 +180,3 @@ 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
-37
View File
@@ -36,43 +36,6 @@ 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)