Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 52a95cebc8 |
+1
-1
@@ -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 模式) =====
|
||||
|
||||
@@ -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,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")
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
@@ -102,14 +108,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 +140,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)
|
||||
@@ -167,8 +165,6 @@ def ad_revenue_report(
|
||||
),
|
||||
"adn": rec.adn,
|
||||
"slot_id": rec.slot_id,
|
||||
"sub_rewards": [],
|
||||
"sub_count": 1,
|
||||
}
|
||||
if rwd is not None:
|
||||
ev.update({
|
||||
@@ -188,88 +184,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 趋势。
|
||||
@@ -390,20 +331,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 +347,5 @@ def ad_revenue_report(
|
||||
"hourly": hourly,
|
||||
"type_stats": type_stats,
|
||||
"dau": dau,
|
||||
"items": main_rows[offset:offset + limit],
|
||||
"items": events[offset:offset + limit],
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
@@ -813,12 +806,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 +895,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 +915,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 +958,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 +966,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 +992,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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -119,14 +119,6 @@ async def price_step(request: Request) -> dict[str, Any]:
|
||||
return await _passthrough(request, "/api/price/step")
|
||||
|
||||
|
||||
@router.post("/trace/epilogue", summary="比价结果页尾声帧 (透传到 pricebot, 用户视角截图入 trace)")
|
||||
async def trace_epilogue(request: Request) -> dict[str, Any]:
|
||||
# App 收到 done、渲染完结果页后,把自己页面的截图(base64)传给 pricebot 存进 trace
|
||||
# 目录并触发重传 —— trace 里补上"用户实际看到的汇总页"(步骤帧只有目标 App 画面)。
|
||||
# body ~几百 KB(截图 base64), 透传壳同 finalize(带 trace_id 亲和)。
|
||||
return await _passthrough(request, "/api/trace/epilogue")
|
||||
|
||||
|
||||
@router.post("/trace/finalize", summary="比价 trace 收尾上云 (透传到 pricebot, 终止/未识别拿 trace_url)")
|
||||
async def trace_finalize(request: Request) -> dict[str, Any]:
|
||||
# 用户终止 / Phase1 未识别没走到 done 帧, pricebot 没上云也没回传 trace_url。客户端收尾时
|
||||
|
||||
+1
-24
@@ -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)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -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 # 扫描周期
|
||||
|
||||
# ===== 短信 =====
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# 穿山甲 GroMore 收益拉取 定时任务 — 运维手册
|
||||
|
||||
> 对象:维护「每天拉穿山甲后台收益入库」这套定时任务的同事。
|
||||
> 🔒 服务器登录信息见**私密交接清单**,不入库。
|
||||
|
||||
## 它是什么
|
||||
admin「广告收益报表」里的「穿山甲后台收益(T+1)」读的是**本地表 `ad_pangle_daily_revenue` 的快照,不是实时查穿山甲**。穿山甲只通过 GroMore 数据 API 给数、且 **T+1**(次日约 10:00 出昨天的数),所以每天得拉一次入库,报表才会往前走。
|
||||
|
||||
- 每天 10:30 跑一轮 `scripts/sync_pangle_revenue.py`,默认 `--days 3` 回补近 3 天。
|
||||
- 维度 = 日期 × 应用(site_id)× 广告位(ad_unit_id);指标 = `revenue`(预估)+ `api_revenue`(结算口径)。
|
||||
- **幂等 upsert**:同一(日期×应用×代码位)重跑只覆盖、不重复,故回补 / 重跑 / catch-up 都安全。
|
||||
- 穿山甲无用户/设备维度 → 只能落「汇总/趋势级」,报表带 user_id 过滤时这块收益置空(显示「-」)。
|
||||
|
||||
## 文件
|
||||
| 项 | 路径 |
|
||||
|---|---|
|
||||
| 脚本入口 | `scripts/sync_pangle_revenue.py` |
|
||||
| 拉取 / 签名 | `app/integrations/pangle_report.py`(签名=参数字典序拼接+secure_key 后 MD5) |
|
||||
| 入库表 | `ad_pangle_daily_revenue`(读写在 `app/repositories/ad_pangle_revenue.py`) |
|
||||
| 凭证 | `.env` 的 `PANGLE_REPORT_USER_ID` / `PANGLE_REPORT_ROLE_ID` / `PANGLE_REPORT_SECURITY_KEY` |
|
||||
| 应用映射 | `.env` 的 `PANGLE_REPORT_SITE_ID_PROD`(5830519)/ `PANGLE_REPORT_SITE_ID_TEST`(5832303) |
|
||||
| systemd 单元 | `deploy/pangle-revenue.{service,timer}` |
|
||||
|
||||
## 上线前置(只做一次)
|
||||
1. **填凭证**:后台「接入中心 → GroMore-API」领 user_id / role_id / Security Key(`secure_key`,**≠ 发奖 m-key**),填进线上 `.env` 的 `PANGLE_REPORT_*`。三项填齐脚本才工作,缺任一 → no-op 退出。
|
||||
2. **子账号要授权**:线上若用子账号(role_id ≠ user_id),需主账号给它授「查看全部数据」,否则接口报 **118**(无权限)。本人自查自己(user_id=role_id)无此问题。
|
||||
3. 凭证泄露:后台「重置 key 值」即可,改完同步线上 `.env` 重跑。
|
||||
|
||||
## 部署(Linux 服务器,需 root)
|
||||
```bash
|
||||
sudo cp deploy/pangle-revenue.{service,timer} /etc/systemd/system/
|
||||
sudo systemctl daemon-reload && sudo systemctl enable --now pangle-revenue.timer
|
||||
systemctl list-timers pangle-revenue.timer # 确认下次触发时间(应是次日 10:30)
|
||||
```
|
||||
|
||||
## 怎么看健康 / 手动跑一次
|
||||
```bash
|
||||
sudo systemctl start pangle-revenue.service # 立即手动跑一轮(不等 10:30)
|
||||
journalctl -u pangle-revenue -n 30 --no-pager # 看日志:拉取区间 / 入库行数 / 新增更新 / 预估收益合计
|
||||
```
|
||||
成功日志形如:`✅ 完成:接口 N 行 → 入库 M 行(跳过 x),新增 a / 更新 b;预估收益合计 ¥19.42`。
|
||||
> 看不到收益、提示 `PANGLE_REPORT_* 未配置`→ 回「上线前置」补 `.env`;报 118 → 子账号没授「查看全部数据」。
|
||||
|
||||
## 本机 Windows 开发(无 systemd)
|
||||
直接手动跑:
|
||||
```
|
||||
.venv\Scripts\python -m scripts.sync_pangle_revenue # 拉昨天
|
||||
.venv\Scripts\python -m scripts.sync_pangle_revenue --days 3 # 回补近 3 天
|
||||
.venv\Scripts\python -m scripts.sync_pangle_revenue --date 2026-06-27 # 指定单天
|
||||
.venv\Scripts\python -m scripts.sync_pangle_revenue --start 2026-06-01 --end 2026-06-27 # 区间回补
|
||||
```
|
||||
|
||||
## 脚本参数
|
||||
- 无参:拉**昨天**(北京时间)。timer 用的是 `--days 3`。
|
||||
- `--days N`:从昨天起往前回补 N 天(含昨天)。
|
||||
- `--date YYYY-MM-DD`:指定单天。
|
||||
- `--start / --end`:指定闭区间(跨度 ≤ 31 天,接口上限 1 个月,超了报 114)。
|
||||
|
||||
## 注意事项
|
||||
- **触发时间**:`OnCalendar=*-*-* 10:30:00`。穿山甲 ~10:00 出数,故别早于 10:00 跑(会拉到空/不全)。
|
||||
- **catch-up**:`Persistent=true` 补跑错过的那一轮;叠加 `--days 3`,漏一两天重新触发即自愈。
|
||||
- **今天 / 今天以前要分开查**:脚本默认只拉昨天及更早,不混查今天(接口约束),无需关心。
|
||||
- **join key 是 `ad_unit_id`(我们配的 104xxx)不是 `code_id`**:`code_id` 是底层各 ADN 代码位,对不上口径;`ad_unit_id='-1'` 是未归因桶。改维度时务必注意(详见脚本头注释)。
|
||||
- **`api_revenue` 很稀疏**:测试应用 ADN 没配 Reporting → 全 0,仅 prod 个别位有;`revenue`(预估)才是稳的主力。
|
||||
- **DB 无关**:sqlite / postgres 均可(upsert 逐行 select-then-write,不像美团 ETL 需要 PG)。
|
||||
- **别和别的触发方式双跑**:本 systemd timer 与「手动 cron / 进程内任务」二选一,虽幂等不会重复入库,纯属多余。
|
||||
- **改脚本 / 改部署**:走 git + PR,由有 root 的人部署。
|
||||
@@ -1,38 +0,0 @@
|
||||
# 每天拉穿山甲 GroMore T+1 天级收益入库 —— 单轮跑,由 pangle-revenue.timer 每天 10:30 触发。
|
||||
# 落 ad_pangle_daily_revenue 表,供 admin 广告收益报表的「穿山甲后台收益(T+1)」区块。
|
||||
#
|
||||
# 仅用于 Linux 服务器;本机 Windows 开发无 systemd,直接手动跑脚本即可:
|
||||
# .venv\Scripts\python -m scripts.sync_pangle_revenue # 拉昨天(北京时间)
|
||||
# .venv\Scripts\python -m scripts.sync_pangle_revenue --days 3 # 回补近 3 天
|
||||
#
|
||||
# 部署(服务器):
|
||||
# sudo cp deploy/pangle-revenue.{service,timer} /etc/systemd/system/
|
||||
# sudo systemctl daemon-reload && sudo systemctl enable --now pangle-revenue.timer
|
||||
# # 手动跑一次验证: sudo systemctl start pangle-revenue.service && journalctl -u pangle-revenue -n 30
|
||||
#
|
||||
# 前置:.env 配好 PANGLE_REPORT_USER_ID / PANGLE_REPORT_ROLE_ID / PANGLE_REPORT_SECURITY_KEY
|
||||
# (后台「接入中心 → GroMore-API」领;子账号需主账号授「查看全部数据」否则接口 118)。
|
||||
# 未配齐这三项 → 脚本自动 no-op 退出,免动 timer。
|
||||
[Unit]
|
||||
Description=Sync Pangle GroMore daily revenue (T+1, one-shot, driven by timer)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
WorkingDirectory=/opt/shaguabijia-app-server
|
||||
Environment="PATH=/opt/shaguabijia-app-server/.venv/bin:/usr/bin:/bin"
|
||||
EnvironmentFile=/opt/shaguabijia-app-server/.env
|
||||
# 默认拉昨天;--days 3 回补近 3 天(幂等 upsert,应对偶发漏跑 + 穿山甲对历史数据订正,重跑无害)。
|
||||
ExecStart=/opt/shaguabijia-app-server/.venv/bin/python -m scripts.sync_pangle_revenue --days 3
|
||||
SyslogIdentifier=pangle-revenue
|
||||
# 仅几个 HTTP 请求 + 小批量入库,通常数秒;给 10min 硬超时防穿山甲接口卡死。
|
||||
TimeoutStartSec=600
|
||||
|
||||
# 与主服务 shaguabijia-app-server.service 同款加固。
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/opt/shaguabijia-app-server
|
||||
ProtectHome=true
|
||||
@@ -1,14 +0,0 @@
|
||||
# 每天 10:30 触发一次穿山甲 GroMore T+1 收益拉取入库(Linux 服务器用)。
|
||||
# 见 pangle-revenue.service 顶部注释的部署步骤。
|
||||
[Unit]
|
||||
Description=Run Pangle GroMore daily revenue sync at 10:30
|
||||
|
||||
[Timer]
|
||||
# 穿山甲 T+1、次日约 10:00 出数;10:30 触发留 30min 余量。要错开整点扎堆可微调到 10:35。
|
||||
OnCalendar=*-*-* 10:30:00
|
||||
# 服务器宕机/重启后,补跑错过的那一轮(而不是干等次日);叠加 --days 3 回补,漏一两天能自愈。
|
||||
Persistent=true
|
||||
AccuracySec=1min
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -190,67 +190,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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user