Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4b94d335d | |||
| 2ed62c789f | |||
| 1548406f29 | |||
| b7b958ed58 |
+338
-13
@@ -5,7 +5,8 @@ user.last_login_at / comparison_record.status / withdraw_order.status)要加索
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import date, datetime, time, timedelta, timezone
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -13,12 +14,27 @@ from sqlalchemy.orm import Session
|
|||||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||||
from app.models.ad_reward import AdRewardRecord
|
from app.models.ad_reward import AdRewardRecord
|
||||||
from app.models.comparison import ComparisonRecord
|
from app.models.comparison import ComparisonRecord
|
||||||
|
from app.models.coupon_state import CouponPromptEngagement
|
||||||
|
from app.models.cps_order import CpsOrder
|
||||||
from app.models.feedback import Feedback
|
from app.models.feedback import Feedback
|
||||||
|
from app.models.savings import SavingsRecord
|
||||||
from app.models.signin import SigninBoostRecord, SigninRecord
|
from app.models.signin import SigninBoostRecord, SigninRecord
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.wallet import CoinTransaction, WithdrawOrder
|
from app.models.wallet import CoinTransaction, WithdrawOrder
|
||||||
|
|
||||||
_BEIJING = timezone(timedelta(hours=8))
|
_BEIJING = timezone(timedelta(hours=8))
|
||||||
|
COUPON_REWARD_BIZ_TYPES = ("reward_video", "ad_reward", "coupon", "coupon_reward")
|
||||||
|
COMPARISON_REWARD_BIZ_TYPES = ("comparison", "compare_reward", "comparison_reward")
|
||||||
|
EXCLUDED_REWARD_BIZ_TYPES = ("invite_inviter", "invite_invitee", "admin_grant")
|
||||||
|
UNCLASSIFIED_FEED_BIZ_TYPES = ("feed_ad_reward",)
|
||||||
|
REGULAR_TASK_EXCLUDED_BIZ_TYPES = (
|
||||||
|
*COUPON_REWARD_BIZ_TYPES,
|
||||||
|
*COMPARISON_REWARD_BIZ_TYPES,
|
||||||
|
*EXCLUDED_REWARD_BIZ_TYPES,
|
||||||
|
*UNCLASSIFIED_FEED_BIZ_TYPES,
|
||||||
|
)
|
||||||
|
MEITUAN_CPS_INVALID_STATUSES = ("4", "5")
|
||||||
|
MEITUAN_CPS_SETTLED_STATUS = "6"
|
||||||
|
|
||||||
|
|
||||||
def _beijing_today_start_utc() -> datetime:
|
def _beijing_today_start_utc() -> datetime:
|
||||||
@@ -31,30 +47,87 @@ def _beijing_today_start_utc() -> datetime:
|
|||||||
def today_dau(db: Session) -> int:
|
def today_dau(db: Session) -> int:
|
||||||
"""今日活跃用户数(DAU):北京时区今天 0 点后登录过(last_login_at)。
|
"""今日活跃用户数(DAU):北京时区今天 0 点后登录过(last_login_at)。
|
||||||
|
|
||||||
大盘与广告收益报表共用此口径,单一来源避免漂移。
|
广告收益报表复用这个函数;历史窗口 DAU 由 dashboard_overview 的 period 口径另算。
|
||||||
⚠️ last_login_at 是单值字段(只存最后一次登录时刻),故只能算「今日」,
|
|
||||||
无法回溯历史某天的 DAU——调用方按此约束决定历史区间是否展示。
|
|
||||||
"""
|
"""
|
||||||
today_start = _beijing_today_start_utc()
|
today_start = _beijing_today_start_utc()
|
||||||
return db.execute(
|
return int(
|
||||||
select(func.count(User.id)).where(User.last_login_at >= today_start)
|
db.execute(select(func.count(User.id)).where(User.last_login_at >= today_start)).scalar_one()
|
||||||
).scalar_one()
|
)
|
||||||
|
|
||||||
|
|
||||||
def dashboard_overview(db: Session) -> dict:
|
def _default_period_end() -> date:
|
||||||
|
"""新版大盘不含今日,默认窗口结束日=北京时间昨天。"""
|
||||||
|
return datetime.now(_BEIJING).date() - timedelta(days=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_period(date_from: date | None, date_to: date | None) -> tuple[date, date]:
|
||||||
|
end = date_to or _default_period_end()
|
||||||
|
start = date_from or end
|
||||||
|
if start > end:
|
||||||
|
start, end = end, start
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
def _period_bounds(date_from: date, date_to: date) -> tuple[datetime, datetime, datetime, datetime]:
|
||||||
|
"""返回同一北京自然日窗口的 UTC aware 边界和北京 naive 边界。
|
||||||
|
|
||||||
|
user.created_at / last_login_at 是 UTC aware 口径;比较/金币等历史上有北京 naive
|
||||||
|
写入,所以两套边界同时保留。
|
||||||
|
"""
|
||||||
|
start_bj = datetime.combine(date_from, time.min, tzinfo=_BEIJING)
|
||||||
|
end_bj = datetime.combine(date_to + timedelta(days=1), time.min, tzinfo=_BEIJING)
|
||||||
|
start_utc = start_bj.astimezone(timezone.utc)
|
||||||
|
end_utc = end_bj.astimezone(timezone.utc)
|
||||||
|
return (
|
||||||
|
start_utc,
|
||||||
|
end_utc,
|
||||||
|
start_bj.replace(tzinfo=None),
|
||||||
|
end_bj.replace(tzinfo=None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _date_range(date_from: date, date_to: date) -> list[date]:
|
||||||
|
days = (date_to - date_from).days
|
||||||
|
return [date_from + timedelta(days=i) for i in range(days + 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def _commission_rate_percent(raw: str | None) -> Decimal | None:
|
||||||
|
"""美团 commissionRate 原值: "300"=3%, "10"=0.1%;也兼容 "3%"。"""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
s = str(raw).strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if s.endswith("%"):
|
||||||
|
return Decimal(s[:-1])
|
||||||
|
val = Decimal(s)
|
||||||
|
except (InvalidOperation, ValueError):
|
||||||
|
return None
|
||||||
|
return val / Decimal("100")
|
||||||
|
|
||||||
|
|
||||||
|
def dashboard_overview(
|
||||||
|
db: Session, *, date_from: date | None = None, date_to: date | None = None
|
||||||
|
) -> dict:
|
||||||
today_start = _beijing_today_start_utc()
|
today_start = _beijing_today_start_utc()
|
||||||
|
period_from, period_to = _normalize_period(date_from, date_to)
|
||||||
|
start_utc, end_utc, start_local, end_local = _period_bounds(period_from, period_to)
|
||||||
|
|
||||||
def _count(model, *conds) -> int:
|
def _count(model, *conds) -> int:
|
||||||
stmt = select(func.count(model.id))
|
stmt = select(func.count(model.id))
|
||||||
if conds:
|
if conds:
|
||||||
stmt = stmt.where(*conds)
|
stmt = stmt.where(*conds)
|
||||||
return db.execute(stmt).scalar_one()
|
return int(db.execute(stmt).scalar_one())
|
||||||
|
|
||||||
def _sum(col, *conds) -> int:
|
def _sum(col, *conds) -> int:
|
||||||
stmt = select(func.coalesce(func.sum(col), 0))
|
stmt = select(func.coalesce(func.sum(col), 0))
|
||||||
if conds:
|
if conds:
|
||||||
stmt = stmt.where(*conds)
|
stmt = stmt.where(*conds)
|
||||||
return db.execute(stmt).scalar_one()
|
return int(db.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
def _user_id_set(stmt) -> set[int]:
|
||||||
|
return {int(v) for v in db.execute(stmt).scalars().all() if v is not None}
|
||||||
|
|
||||||
# ===== 用户 =====
|
# ===== 用户 =====
|
||||||
by_status = dict(
|
by_status = dict(
|
||||||
@@ -74,6 +147,204 @@ def dashboard_overview(db: Session) -> dict:
|
|||||||
comparison_total = _count(ComparisonRecord)
|
comparison_total = _count(ComparisonRecord)
|
||||||
comparison_success = _count(ComparisonRecord, ComparisonRecord.status == "success")
|
comparison_success = _count(ComparisonRecord, ComparisonRecord.status == "success")
|
||||||
success_rate = round(comparison_success / comparison_total, 4) if comparison_total else 0.0
|
success_rate = round(comparison_success / comparison_total, 4) if comparison_total else 0.0
|
||||||
|
period_comparison_conds = (
|
||||||
|
ComparisonRecord.created_at >= start_local,
|
||||||
|
ComparisonRecord.created_at < end_local,
|
||||||
|
)
|
||||||
|
period_comparison_total = _count(ComparisonRecord, *period_comparison_conds)
|
||||||
|
period_comparison_success = _count(
|
||||||
|
ComparisonRecord,
|
||||||
|
*period_comparison_conds,
|
||||||
|
ComparisonRecord.status == "success",
|
||||||
|
)
|
||||||
|
period_comparison_success_rate = (
|
||||||
|
round(period_comparison_success / period_comparison_total, 4)
|
||||||
|
if period_comparison_total
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
|
period_saved_positive_count = _count(
|
||||||
|
ComparisonRecord,
|
||||||
|
*period_comparison_conds,
|
||||||
|
ComparisonRecord.status == "success",
|
||||||
|
ComparisonRecord.saved_amount_cents > 0,
|
||||||
|
)
|
||||||
|
period_saved_positive_sum = _sum(
|
||||||
|
ComparisonRecord.saved_amount_cents,
|
||||||
|
*period_comparison_conds,
|
||||||
|
ComparisonRecord.status == "success",
|
||||||
|
ComparisonRecord.saved_amount_cents > 0,
|
||||||
|
)
|
||||||
|
period_avg_saved_cents = (
|
||||||
|
round(period_saved_positive_sum / period_saved_positive_count)
|
||||||
|
if period_saved_positive_count
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
period_avg_duration_ms = db.execute(
|
||||||
|
select(func.avg(ComparisonRecord.total_ms)).where(
|
||||||
|
*period_comparison_conds,
|
||||||
|
ComparisonRecord.total_ms.is_not(None),
|
||||||
|
ComparisonRecord.total_ms > 0,
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
period_avg_duration_ms = (
|
||||||
|
round(float(period_avg_duration_ms))
|
||||||
|
if period_avg_duration_ms is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
ordered_exists = (
|
||||||
|
select(SavingsRecord.id)
|
||||||
|
.where(
|
||||||
|
SavingsRecord.user_id == ComparisonRecord.user_id,
|
||||||
|
SavingsRecord.source == "compare",
|
||||||
|
SavingsRecord.shop_name.is_not(None),
|
||||||
|
SavingsRecord.shop_name == ComparisonRecord.store_name,
|
||||||
|
)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
period_ordered_count = _count(
|
||||||
|
ComparisonRecord,
|
||||||
|
*period_comparison_conds,
|
||||||
|
ComparisonRecord.store_name.is_not(None),
|
||||||
|
ordered_exists,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ===== 日期窗口用户 =====
|
||||||
|
period_new_user_ids = _user_id_set(
|
||||||
|
select(User.id).where(User.created_at >= start_utc, User.created_at < end_utc)
|
||||||
|
)
|
||||||
|
login_user_ids = _user_id_set(
|
||||||
|
select(User.id).where(User.last_login_at >= start_utc, User.last_login_at < end_utc)
|
||||||
|
)
|
||||||
|
compare_user_ids = _user_id_set(
|
||||||
|
select(ComparisonRecord.user_id).where(*period_comparison_conds)
|
||||||
|
)
|
||||||
|
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_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)
|
||||||
|
if period_new_user_ids
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
trend_points: list[dict] = []
|
||||||
|
for cur_date in _date_range(period_from, period_to):
|
||||||
|
day_start_utc, day_end_utc, day_start_local, day_end_local = _period_bounds(
|
||||||
|
cur_date, cur_date
|
||||||
|
)
|
||||||
|
daily_comparison_conds = (
|
||||||
|
ComparisonRecord.created_at >= day_start_local,
|
||||||
|
ComparisonRecord.created_at < day_end_local,
|
||||||
|
)
|
||||||
|
daily_login_user_ids = _user_id_set(
|
||||||
|
select(User.id).where(
|
||||||
|
User.last_login_at >= day_start_utc,
|
||||||
|
User.last_login_at < day_end_utc,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
daily_compare_user_ids = _user_id_set(
|
||||||
|
select(ComparisonRecord.user_id).where(*daily_comparison_conds)
|
||||||
|
)
|
||||||
|
daily_coupon_user_ids = _user_id_set(
|
||||||
|
select(CouponPromptEngagement.user_id).where(
|
||||||
|
CouponPromptEngagement.engage_date == cur_date,
|
||||||
|
CouponPromptEngagement.engage_type == "claim_started",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
trend_points.append(
|
||||||
|
{
|
||||||
|
"date": cur_date,
|
||||||
|
"active_users": len(
|
||||||
|
daily_login_user_ids | daily_compare_user_ids | daily_coupon_user_ids
|
||||||
|
),
|
||||||
|
"new_users": _count(
|
||||||
|
User,
|
||||||
|
User.created_at >= day_start_utc,
|
||||||
|
User.created_at < day_end_utc,
|
||||||
|
),
|
||||||
|
"comparisons": _count(ComparisonRecord, *daily_comparison_conds),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
period_coin_conds = (
|
||||||
|
CoinTransaction.created_at >= start_local,
|
||||||
|
CoinTransaction.created_at < end_local,
|
||||||
|
CoinTransaction.amount > 0,
|
||||||
|
)
|
||||||
|
period_reward_video_coin_total = _sum(
|
||||||
|
CoinTransaction.amount,
|
||||||
|
*period_coin_conds,
|
||||||
|
CoinTransaction.biz_type.in_(("reward_video", "ad_reward")),
|
||||||
|
)
|
||||||
|
period_feed_ad_coin_total = _sum(
|
||||||
|
CoinTransaction.amount,
|
||||||
|
*period_coin_conds,
|
||||||
|
CoinTransaction.biz_type == "feed_ad_reward",
|
||||||
|
)
|
||||||
|
period_signin_coin_total = _sum(
|
||||||
|
CoinTransaction.amount,
|
||||||
|
*period_coin_conds,
|
||||||
|
CoinTransaction.biz_type == "signin",
|
||||||
|
)
|
||||||
|
period_signin_boost_coin_total = _sum(
|
||||||
|
CoinTransaction.amount,
|
||||||
|
*period_coin_conds,
|
||||||
|
CoinTransaction.biz_type == "signin_boost",
|
||||||
|
)
|
||||||
|
period_task_coin_total = _sum(
|
||||||
|
CoinTransaction.amount,
|
||||||
|
*period_coin_conds,
|
||||||
|
CoinTransaction.biz_type.like("task_%"),
|
||||||
|
)
|
||||||
|
period_coupon_reward_coin_total = _sum(
|
||||||
|
CoinTransaction.amount,
|
||||||
|
*period_coin_conds,
|
||||||
|
CoinTransaction.biz_type.in_(COUPON_REWARD_BIZ_TYPES),
|
||||||
|
)
|
||||||
|
period_comparison_reward_coin_total = _sum(
|
||||||
|
CoinTransaction.amount,
|
||||||
|
*period_coin_conds,
|
||||||
|
CoinTransaction.biz_type.in_(COMPARISON_REWARD_BIZ_TYPES),
|
||||||
|
)
|
||||||
|
period_regular_task_coin_total = _sum(
|
||||||
|
CoinTransaction.amount,
|
||||||
|
*period_coin_conds,
|
||||||
|
CoinTransaction.biz_type.notin_(REGULAR_TASK_EXCLUDED_BIZ_TYPES),
|
||||||
|
)
|
||||||
|
period_meituan_orders = list(
|
||||||
|
db.execute(
|
||||||
|
select(CpsOrder).where(
|
||||||
|
CpsOrder.pay_time >= start_utc,
|
||||||
|
CpsOrder.pay_time < end_utc,
|
||||||
|
)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
period_meituan_valid_orders = [
|
||||||
|
o for o in period_meituan_orders if o.mt_status not in MEITUAN_CPS_INVALID_STATUSES
|
||||||
|
]
|
||||||
|
period_meituan_hit_count = 0
|
||||||
|
period_meituan_miss_count = 0
|
||||||
|
period_meituan_unknown_rate_count = 0
|
||||||
|
for order in period_meituan_valid_orders:
|
||||||
|
rate = _commission_rate_percent(order.commission_rate)
|
||||||
|
if rate is None:
|
||||||
|
period_meituan_unknown_rate_count += 1
|
||||||
|
elif rate < Decimal("1"):
|
||||||
|
period_meituan_miss_count += 1
|
||||||
|
else:
|
||||||
|
period_meituan_hit_count += 1
|
||||||
|
period_meituan_hit_denominator = period_meituan_hit_count + period_meituan_miss_count
|
||||||
|
period_meituan_hit_rate = (
|
||||||
|
round(period_meituan_hit_count / period_meituan_hit_denominator, 4)
|
||||||
|
if period_meituan_hit_denominator
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"users": {
|
"users": {
|
||||||
@@ -132,7 +403,61 @@ def dashboard_overview(db: Session) -> dict:
|
|||||||
"success": comparison_success,
|
"success": comparison_success,
|
||||||
"success_rate": success_rate,
|
"success_rate": success_rate,
|
||||||
},
|
},
|
||||||
"feedback": {"new": _count(Feedback, Feedback.status.in_(("pending", "new")))},
|
"period": {
|
||||||
# CPS 收入数据源未接(referral-link 只换链接,转化/佣金未回收)→ 前端显示"待接入"。
|
"date_from": period_from,
|
||||||
"cps": {"available": False, "note": "CPS 转化数据未接入(P2)"},
|
"date_to": period_to,
|
||||||
|
"users": {
|
||||||
|
"new": len(period_new_user_ids),
|
||||||
|
"active": len(period_active_user_ids),
|
||||||
|
"retained_new_users": len(period_retained_new_user_ids),
|
||||||
|
"retention_rate": period_retention_rate,
|
||||||
|
"retention_note": (
|
||||||
|
"近似口径:登录(last_login_at)+已上报比价记录+领券claim_started;"
|
||||||
|
"尚不包含未完成上报的比价开始事件"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"comparison": {
|
||||||
|
"total": period_comparison_total,
|
||||||
|
"success": period_comparison_success,
|
||||||
|
"success_rate": period_comparison_success_rate,
|
||||||
|
"ordered": period_ordered_count,
|
||||||
|
"average_duration_ms": period_avg_duration_ms,
|
||||||
|
"average_saved_cents": period_avg_saved_cents,
|
||||||
|
},
|
||||||
|
"coins": {
|
||||||
|
"granted_total": _sum(CoinTransaction.amount, *period_coin_conds),
|
||||||
|
"reward_video_coin_total": period_reward_video_coin_total,
|
||||||
|
"feed_ad_coin_total": period_feed_ad_coin_total,
|
||||||
|
"signin_coin_total": period_signin_coin_total,
|
||||||
|
"signin_boost_coin_total": period_signin_boost_coin_total,
|
||||||
|
"task_coin_total": period_task_coin_total,
|
||||||
|
"coupon_reward_coin_total": period_coupon_reward_coin_total,
|
||||||
|
"comparison_reward_coin_total": period_comparison_reward_coin_total,
|
||||||
|
"regular_task_coin_total": period_regular_task_coin_total,
|
||||||
|
},
|
||||||
|
"cash": {
|
||||||
|
"withdraw_success_cents": _sum(
|
||||||
|
WithdrawOrder.amount_cents,
|
||||||
|
WithdrawOrder.status == "success",
|
||||||
|
WithdrawOrder.created_at >= start_local,
|
||||||
|
WithdrawOrder.created_at < end_local,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"trend": trend_points,
|
||||||
|
},
|
||||||
|
"feedback": {
|
||||||
|
"new": _count(Feedback, Feedback.status.in_(("pending", "new"))),
|
||||||
|
},
|
||||||
|
"cps": {
|
||||||
|
"available": True,
|
||||||
|
"note": "美团 CPS 读 cps_order 对账订单;淘宝/京东佣金暂空",
|
||||||
|
"meituan_order_count": len(period_meituan_valid_orders),
|
||||||
|
"meituan_commission_cents": sum(
|
||||||
|
o.commission_cents or 0 for o in period_meituan_valid_orders
|
||||||
|
),
|
||||||
|
"meituan_hit_count": period_meituan_hit_count,
|
||||||
|
"meituan_miss_count": period_meituan_miss_count,
|
||||||
|
"meituan_unknown_rate_count": period_meituan_unknown_rate_count,
|
||||||
|
"meituan_hit_rate": period_meituan_hit_rate,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import date as _date, datetime, time as _dt_time, timedelta, timezone
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
|
||||||
@@ -340,24 +340,77 @@ def generate_referral_links(
|
|||||||
|
|
||||||
|
|
||||||
# ───────────── 订单对账 ─────────────
|
# ───────────── 订单对账 ─────────────
|
||||||
|
_BEIJING = timezone(timedelta(hours=8))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_day(value: str | None, *, field: str) -> _date | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return _date.fromisoformat(value)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=422, detail=f"{field} 需为 YYYY-MM-DD") from e
|
||||||
|
|
||||||
|
|
||||||
|
def _reconcile_range_to_ts(
|
||||||
|
date_from: _date | None, date_to: _date | None, days: int
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
if date_from is None and date_to is None:
|
||||||
|
now = int(time.time())
|
||||||
|
return now - days * 86400, now
|
||||||
|
|
||||||
|
start_day = date_from or date_to
|
||||||
|
end_day = date_to or date_from
|
||||||
|
if start_day is None or end_day is None:
|
||||||
|
raise HTTPException(status_code=422, detail="日期参数不完整")
|
||||||
|
if start_day > end_day:
|
||||||
|
start_day, end_day = end_day, start_day
|
||||||
|
if (end_day - start_day).days + 1 > 90:
|
||||||
|
raise HTTPException(status_code=422, detail="美团订单查询最长 90 天")
|
||||||
|
|
||||||
|
start_dt = datetime.combine(start_day, _dt_time.min, tzinfo=_BEIJING)
|
||||||
|
end_dt = datetime.combine(end_day + timedelta(days=1), _dt_time.min, tzinfo=_BEIJING)
|
||||||
|
return int(start_dt.timestamp()), int(end_dt.timestamp())
|
||||||
|
|
||||||
|
|
||||||
@router.post("/orders/reconcile", response_model=CpsReconcileResult, summary="拉取美团订单对账")
|
@router.post("/orders/reconcile", response_model=CpsReconcileResult, summary="拉取美团订单对账")
|
||||||
def reconcile_orders(
|
def reconcile_orders(
|
||||||
request: Request,
|
request: Request,
|
||||||
admin: Annotated[AdminUser, Depends(require_role("finance"))],
|
admin: Annotated[AdminUser, Depends(require_role("finance"))],
|
||||||
db: AdminDb,
|
db: AdminDb,
|
||||||
days: Annotated[int, Query(ge=1, le=90)] = 7,
|
date_from: Annotated[str | None, Query(description="起始日 YYYY-MM-DD")] = None,
|
||||||
|
date_to: Annotated[str | None, Query(description="结束日 YYYY-MM-DD")] = None,
|
||||||
|
days: Annotated[int, Query(ge=1, le=90, description="未传日期时默认拉近 N 天")] = 7,
|
||||||
sid: Annotated[str | None, Query(max_length=64)] = None,
|
sid: Annotated[str | None, Query(max_length=64)] = None,
|
||||||
|
query_time_type: Annotated[int, Query(ge=1, le=2)] = 1,
|
||||||
) -> CpsReconcileResult:
|
) -> CpsReconcileResult:
|
||||||
now = int(time.time())
|
start_ts, end_ts = _reconcile_range_to_ts(
|
||||||
|
_parse_day(date_from, field="date_from"),
|
||||||
|
_parse_day(date_to, field="date_to"),
|
||||||
|
days,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
result = cps_repo.reconcile_orders(
|
result = cps_repo.reconcile_orders(
|
||||||
db, start_time=now - days * 86400, end_time=now, sid=sid,
|
db,
|
||||||
|
start_time=start_ts,
|
||||||
|
end_time=end_ts,
|
||||||
|
query_time_type=query_time_type,
|
||||||
|
sid=sid,
|
||||||
)
|
)
|
||||||
except MeituanCpsError as e:
|
except MeituanCpsError as e:
|
||||||
raise HTTPException(status_code=502, detail=f"美团拉单失败: {e}") from e
|
raise HTTPException(status_code=502, detail=f"美团拉单失败: {e}") from e
|
||||||
write_audit(
|
write_audit(
|
||||||
db, admin, action="cps.order.reconcile", target_type="cps_order", target_id=None,
|
db, admin, action="cps.order.reconcile", target_type="cps_order", target_id=None,
|
||||||
detail={"days": days, "sid": sid, **result}, ip=get_client_ip(request), commit=True,
|
detail={
|
||||||
|
"date_from": date_from,
|
||||||
|
"date_to": date_to,
|
||||||
|
"days": days,
|
||||||
|
"sid": sid,
|
||||||
|
"query_time_type": query_time_type,
|
||||||
|
**result,
|
||||||
|
},
|
||||||
|
ip=get_client_ip(request),
|
||||||
|
commit=True,
|
||||||
)
|
)
|
||||||
return CpsReconcileResult(**result)
|
return CpsReconcileResult(**result)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"""admin 数据大盘(只读聚合)。"""
|
"""admin 数据大盘(只读聚合)。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from datetime import date
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
from app.admin.deps import AdminDb, get_current_admin
|
from app.admin.deps import AdminDb, get_current_admin
|
||||||
from app.admin.repositories import stats
|
from app.admin.repositories import stats
|
||||||
@@ -15,5 +17,11 @@ router = APIRouter(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/overview", response_model=DashboardOverview, summary="大盘核心指标")
|
@router.get("/overview", response_model=DashboardOverview, summary="大盘核心指标")
|
||||||
def overview(db: AdminDb) -> DashboardOverview:
|
def overview(
|
||||||
return DashboardOverview.model_validate(stats.dashboard_overview(db))
|
db: AdminDb,
|
||||||
|
date_from: date | None = Query(None, description="北京时间自然日起始日 YYYY-MM-DD"),
|
||||||
|
date_to: date | None = Query(None, description="北京时间自然日结束日 YYYY-MM-DD"),
|
||||||
|
) -> DashboardOverview:
|
||||||
|
return DashboardOverview.model_validate(
|
||||||
|
stats.dashboard_overview(db, date_from=date_from, date_to=date_to)
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""admin 大盘 schemas(对应 stats.dashboard_overview 的嵌套结构)。"""
|
"""admin 大盘 schemas(对应 stats.dashboard_overview 的嵌套结构)。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
@@ -38,6 +40,56 @@ class DashboardComparison(BaseModel):
|
|||||||
success_rate: float
|
success_rate: float
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardPeriodUsers(BaseModel):
|
||||||
|
new: int
|
||||||
|
active: int
|
||||||
|
retained_new_users: int
|
||||||
|
retention_rate: float | None = None
|
||||||
|
retention_note: str
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardPeriodComparison(BaseModel):
|
||||||
|
total: int
|
||||||
|
success: int
|
||||||
|
success_rate: float
|
||||||
|
ordered: int
|
||||||
|
average_duration_ms: int | None = None
|
||||||
|
average_saved_cents: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardPeriodCoins(BaseModel):
|
||||||
|
granted_total: int
|
||||||
|
reward_video_coin_total: int = 0
|
||||||
|
feed_ad_coin_total: int = 0
|
||||||
|
signin_coin_total: int = 0
|
||||||
|
signin_boost_coin_total: int = 0
|
||||||
|
task_coin_total: int = 0
|
||||||
|
coupon_reward_coin_total: int = 0
|
||||||
|
comparison_reward_coin_total: int = 0
|
||||||
|
regular_task_coin_total: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardPeriodCash(BaseModel):
|
||||||
|
withdraw_success_cents: int
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardTrendPoint(BaseModel):
|
||||||
|
date: date
|
||||||
|
active_users: int
|
||||||
|
new_users: int
|
||||||
|
comparisons: int
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardPeriod(BaseModel):
|
||||||
|
date_from: date
|
||||||
|
date_to: date
|
||||||
|
users: DashboardPeriodUsers
|
||||||
|
comparison: DashboardPeriodComparison
|
||||||
|
coins: DashboardPeriodCoins
|
||||||
|
cash: DashboardPeriodCash
|
||||||
|
trend: list[DashboardTrendPoint] = []
|
||||||
|
|
||||||
|
|
||||||
class DashboardFeedback(BaseModel):
|
class DashboardFeedback(BaseModel):
|
||||||
new: int
|
new: int
|
||||||
|
|
||||||
@@ -45,6 +97,12 @@ class DashboardFeedback(BaseModel):
|
|||||||
class DashboardCps(BaseModel):
|
class DashboardCps(BaseModel):
|
||||||
available: bool
|
available: bool
|
||||||
note: str
|
note: str
|
||||||
|
meituan_order_count: int = 0
|
||||||
|
meituan_commission_cents: int = 0
|
||||||
|
meituan_hit_count: int = 0
|
||||||
|
meituan_miss_count: int = 0
|
||||||
|
meituan_unknown_rate_count: int = 0
|
||||||
|
meituan_hit_rate: float | None = None
|
||||||
|
|
||||||
|
|
||||||
class DashboardOverview(BaseModel):
|
class DashboardOverview(BaseModel):
|
||||||
@@ -52,5 +110,6 @@ class DashboardOverview(BaseModel):
|
|||||||
coins: DashboardCoins
|
coins: DashboardCoins
|
||||||
cash: DashboardCash
|
cash: DashboardCash
|
||||||
comparison: DashboardComparison
|
comparison: DashboardComparison
|
||||||
|
period: DashboardPeriod
|
||||||
feedback: DashboardFeedback
|
feedback: DashboardFeedback
|
||||||
cps: DashboardCps
|
cps: DashboardCps
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hmac
|
import hmac
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Header, HTTPException, status
|
from fastapi import APIRouter, Header, HTTPException, status
|
||||||
@@ -21,6 +22,7 @@ from app.repositories import launch_confirm_sample as repo
|
|||||||
from app.schemas.launch_confirm_sample import (
|
from app.schemas.launch_confirm_sample import (
|
||||||
LaunchConfirmSampleIn,
|
LaunchConfirmSampleIn,
|
||||||
LaunchConfirmSampleOut,
|
LaunchConfirmSampleOut,
|
||||||
|
LaunchConfirmSampleRow,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("shagua.internal.launch_confirm")
|
logger = logging.getLogger("shagua.internal.launch_confirm")
|
||||||
@@ -61,3 +63,34 @@ def report_launch_confirm_sample(
|
|||||||
payload.exec_success, payload.trace_id,
|
payload.exec_success, payload.trace_id,
|
||||||
)
|
)
|
||||||
return LaunchConfirmSampleOut(id=sid)
|
return LaunchConfirmSampleOut(id=sid)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/launch-confirm-samples",
|
||||||
|
response_model=list[LaunchConfirmSampleRow],
|
||||||
|
summary="启动确认窗兜底样本列表(沉淀脚本 distill 读; server→server, 不走 JWT)",
|
||||||
|
)
|
||||||
|
def list_launch_confirm_samples(
|
||||||
|
db: DbSession,
|
||||||
|
x_internal_secret: Annotated[str | None, Header()] = None,
|
||||||
|
exec_success: bool | None = None,
|
||||||
|
host_package: str | None = None,
|
||||||
|
since_days: int | None = None,
|
||||||
|
limit: int = 1000,
|
||||||
|
) -> list[LaunchConfirmSampleRow]:
|
||||||
|
"""读样本供 pricebot 的 distill_launch_confirm.py 聚合沉淀回 PROFILES。
|
||||||
|
|
||||||
|
与上报端点同一把共享密钥;exec_success/host_package/since_days 均可选,limit 默认 1000。
|
||||||
|
"""
|
||||||
|
_check_secret(x_internal_secret)
|
||||||
|
since = None
|
||||||
|
if since_days and since_days > 0:
|
||||||
|
since = datetime.now(timezone.utc) - timedelta(days=since_days)
|
||||||
|
rows = repo.list_samples(
|
||||||
|
db,
|
||||||
|
exec_success=exec_success,
|
||||||
|
host_package=host_package,
|
||||||
|
since=since,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return [LaunchConfirmSampleRow.model_validate(r) for r in rows]
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import httpx
|
|||||||
from fastapi import APIRouter, HTTPException, Request, status
|
from fastapi import APIRouter, HTTPException, Request, status
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.pricebot_client import get_pricebot_client
|
||||||
from app.core.pricebot_router import pick_pricebot
|
from app.core.pricebot_router import pick_pricebot
|
||||||
|
|
||||||
logger = logging.getLogger("shagua.compare")
|
logger = logging.getLogger("shagua.compare")
|
||||||
@@ -65,10 +66,10 @@ async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
client = get_pricebot_client()
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
url, content=raw, headers={"Content-Type": "application/json"}
|
url, content=raw, headers={"Content-Type": "application/json"}, timeout=timeout
|
||||||
)
|
)
|
||||||
except httpx.RequestError as e:
|
except httpx.RequestError as e:
|
||||||
logger.error("[pricebot] request failed: %s", e)
|
logger.error("[pricebot] request failed: %s", e)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from fastapi.concurrency import run_in_threadpool
|
|||||||
|
|
||||||
from app.api.deps import CurrentUser, DbSession
|
from app.api.deps import CurrentUser, DbSession
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.pricebot_client import get_pricebot_client
|
||||||
from app.core.pricebot_router import pick_pricebot
|
from app.core.pricebot_router import pick_pricebot
|
||||||
from app.db.session import SessionLocal
|
from app.db.session import SessionLocal
|
||||||
from app.repositories import coupon_state as coupon_repo
|
from app.repositories import coupon_state as coupon_repo
|
||||||
@@ -141,10 +142,10 @@ async def coupon_step(
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
client = get_pricebot_client()
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
url, content=raw, headers={"Content-Type": "application/json"}
|
url, content=raw, headers={"Content-Type": "application/json"}, timeout=timeout
|
||||||
)
|
)
|
||||||
except httpx.RequestError as e:
|
except httpx.RequestError as e:
|
||||||
logger.error("[pricebot] request failed: %s", e)
|
logger.error("[pricebot] request failed: %s", e)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""透传到 pricebot 的共享 httpx.AsyncClient 单例。
|
||||||
|
|
||||||
|
为什么不能每请求新建(coupon.py / compare.py 老写法 async with httpx.AsyncClient(...)):
|
||||||
|
① 每次构造都重建一套 SSL 上下文(httpx.create_ssl_context 加载 certifi CA),实测
|
||||||
|
~1s+/次;而 pricebot 是纯 http 透传,根本用不到 TLS → 纯浪费,且每帧重交一次。
|
||||||
|
② trust_env 默认 True 会读进程 HTTP_PROXY,把 http://localhost:8000 这条本地透传整个
|
||||||
|
塞进本机代理(如 Clash 7897),恒定再多几秒。
|
||||||
|
单例:启动只建一次(SSL/连接池一次性),keep-alive 复用 TCP,每帧降到个位数 ms。
|
||||||
|
trust_env=False:对齐 integrations/meituan.py 的既有约定,不被进程代理误导,直连 pricebot。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
_client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_pricebot_client() -> httpx.AsyncClient:
|
||||||
|
"""取透传单例。lifespan 启动会预热;未预热(如测试态)懒建兜底。
|
||||||
|
|
||||||
|
超时不在此固化(coupon 30s / compare 60s 不同),由调用点 client.post(timeout=...) 传。
|
||||||
|
懒建无 await,asyncio 单线程下不会有并发竞态。
|
||||||
|
"""
|
||||||
|
global _client
|
||||||
|
if _client is None:
|
||||||
|
_client = httpx.AsyncClient(trust_env=False)
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
async def aclose_pricebot_client() -> None:
|
||||||
|
"""lifespan 关停时调,优雅关连接池。"""
|
||||||
|
global _client
|
||||||
|
if _client is not None:
|
||||||
|
await _client.aclose()
|
||||||
|
_client = None
|
||||||
@@ -49,6 +49,7 @@ from app.core.daily_exchange_worker import (
|
|||||||
stop_daily_exchange_worker,
|
stop_daily_exchange_worker,
|
||||||
)
|
)
|
||||||
from app.core.logging import setup_logging
|
from app.core.logging import setup_logging
|
||||||
|
from app.core.pricebot_client import aclose_pricebot_client, get_pricebot_client
|
||||||
from app.core.withdraw_reconcile_worker import (
|
from app.core.withdraw_reconcile_worker import (
|
||||||
start_withdraw_reconcile_worker,
|
start_withdraw_reconcile_worker,
|
||||||
stop_withdraw_reconcile_worker,
|
stop_withdraw_reconcile_worker,
|
||||||
@@ -68,6 +69,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
|||||||
settings.APP_DEBUG,
|
settings.APP_DEBUG,
|
||||||
settings.DATABASE_URL.split("://", 1)[0],
|
settings.DATABASE_URL.split("://", 1)[0],
|
||||||
)
|
)
|
||||||
|
get_pricebot_client() # 预热透传 client:把建 SSL 上下文的一次性成本付在启动,首个领券请求即热
|
||||||
reconcile_task = start_withdraw_reconcile_worker()
|
reconcile_task = start_withdraw_reconcile_worker()
|
||||||
heartbeat_task = start_heartbeat_monitor()
|
heartbeat_task = start_heartbeat_monitor()
|
||||||
daily_exchange_task = start_daily_exchange_worker()
|
daily_exchange_task = start_daily_exchange_worker()
|
||||||
@@ -77,6 +79,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
|||||||
await stop_heartbeat_monitor(heartbeat_task)
|
await stop_heartbeat_monitor(heartbeat_task)
|
||||||
await stop_withdraw_reconcile_worker(reconcile_task)
|
await stop_withdraw_reconcile_worker(reconcile_task)
|
||||||
await stop_daily_exchange_worker(daily_exchange_task)
|
await stop_daily_exchange_worker(daily_exchange_task)
|
||||||
|
await aclose_pricebot_client()
|
||||||
logger.info("shutting down")
|
logger.info("shutting down")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from app.models.coupon_state import ( # noqa: F401
|
|||||||
CouponDailyCompletion,
|
CouponDailyCompletion,
|
||||||
CouponPromptEngagement,
|
CouponPromptEngagement,
|
||||||
)
|
)
|
||||||
|
from app.models.cps_order import CpsOrder # noqa: F401
|
||||||
from app.models.feedback import Feedback # noqa: F401
|
from app.models.feedback import Feedback # noqa: F401
|
||||||
from app.models.invite import InviteRelation # noqa: F401
|
from app.models.invite import InviteRelation # noqa: F401
|
||||||
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
|
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models.launch_confirm_sample import LaunchConfirmSample
|
from app.models.launch_confirm_sample import LaunchConfirmSample
|
||||||
@@ -33,3 +35,27 @@ def insert_sample(db: Session, payload: LaunchConfirmSampleIn) -> int:
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(row)
|
db.refresh(row)
|
||||||
return row.id
|
return row.id
|
||||||
|
|
||||||
|
|
||||||
|
def list_samples(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
exec_success: Optional[bool] = None,
|
||||||
|
host_package: Optional[str] = None,
|
||||||
|
since: Optional[datetime] = None,
|
||||||
|
limit: int = 1000,
|
||||||
|
) -> list[LaunchConfirmSample]:
|
||||||
|
"""按条件查样本(pricebot 的 distill_launch_confirm.py 沉淀工具读)。created_at 升序。
|
||||||
|
|
||||||
|
过滤项都可空:exec_success(只看放行成功的)/ host_package(只看某宿主包)/
|
||||||
|
since(>= created_at)。limit 兜底防一次拉爆全表。
|
||||||
|
"""
|
||||||
|
stmt = select(LaunchConfirmSample)
|
||||||
|
if exec_success is not None:
|
||||||
|
stmt = stmt.where(LaunchConfirmSample.exec_success == exec_success)
|
||||||
|
if host_package:
|
||||||
|
stmt = stmt.where(LaunchConfirmSample.host_package == host_package)
|
||||||
|
if since is not None:
|
||||||
|
stmt = stmt.where(LaunchConfirmSample.created_at >= since)
|
||||||
|
stmt = stmt.order_by(LaunchConfirmSample.created_at.asc()).limit(limit)
|
||||||
|
return list(db.execute(stmt).scalars().all())
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ class ComparisonRecordIn(BaseModel):
|
|||||||
# pricebot done.params.trace_url 原样上报,落库供记录页「复制调试链接」(dir 名含落盘
|
# pricebot done.params.trace_url 原样上报,落库供记录页「复制调试链接」(dir 名含落盘
|
||||||
# 时分秒前端拼不出,必须由后端透传)。
|
# 时分秒前端拼不出,必须由后端透传)。
|
||||||
trace_url: str | None = Field(None, description="本次比价公网调试链接")
|
trace_url: str | None = Field(None, description="本次比价公网调试链接")
|
||||||
|
total_ms: int | None = Field(None, description="整场比价墙钟耗时(ms)")
|
||||||
|
|
||||||
# ===== debug 维度(客户端采集上报;旧客户端不带 → None。仅 admin 比价记录页用)=====
|
# ===== debug 维度(客户端采集上报;旧客户端不带 → None。仅 admin 比价记录页用)=====
|
||||||
# 必须显式声明,否则 model_dump() 落 raw_payload 时被 pydantic 静默丢弃(同上面 coupon_saved 的坑)。
|
# 必须显式声明,否则 model_dump() 落 raw_payload 时被 pydantic 静默丢弃(同上面 coupon_saved 的坑)。
|
||||||
@@ -172,6 +173,7 @@ class ComparisonRecordOut(BaseModel):
|
|||||||
items: list = []
|
items: list = []
|
||||||
comparison_results: list = []
|
comparison_results: list = []
|
||||||
skipped_dish_names: list = []
|
skipped_dish_names: list = []
|
||||||
|
total_ms: int | None = None
|
||||||
# 「已下单」(店级):该店名在该用户真实下单(source='compare')里出现过即 True。
|
# 「已下单」(店级):该店名在该用户真实下单(source='compare')里出现过即 True。
|
||||||
# 由 list_records 动态算出挂在 ORM 实例上(非 DB 列),from_attributes 读出;缺省 False。
|
# 由 list_records 动态算出挂在 ORM 实例上(非 DB 列),from_attributes 读出;缺省 False。
|
||||||
ordered: bool = False
|
ordered: bool = False
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"""启动确认窗兜底样本的内部上报模型(pricebot → app-server)。"""
|
"""启动确认窗兜底样本的内部上报模型(pricebot → app-server)。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
class LaunchConfirmSampleIn(BaseModel):
|
class LaunchConfirmSampleIn(BaseModel):
|
||||||
@@ -24,3 +26,20 @@ class LaunchConfirmSampleOut(BaseModel):
|
|||||||
"""落库结果。"""
|
"""落库结果。"""
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
|
|
||||||
|
|
||||||
|
class LaunchConfirmSampleRow(BaseModel):
|
||||||
|
"""单条样本(列表读出;沉淀脚本 distill 用,payload 原样带出)。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
trace_id: str | None = None
|
||||||
|
device_id: str | None = None
|
||||||
|
host_package: str | None = None
|
||||||
|
target_app: str | None = None
|
||||||
|
system_locale: str | None = None
|
||||||
|
exec_success: bool = False
|
||||||
|
dialog_title: str | None = None
|
||||||
|
payload: dict | None = None
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ B 安装并首启 App
|
|||||||
└─ POST /api/v1/invite/bind { invite_code, channel="clipboard" }
|
└─ POST /api/v1/invite/bind { invite_code, channel="clipboard" }
|
||||||
↓
|
↓
|
||||||
后端 repositories/invite.py bind()
|
后端 repositories/invite.py bind()
|
||||||
└─ 过四道防线 → 建 invite_relation + 给 A、B 各发金币(同事务原子提交)
|
└─ 过四道防线 → 建 invite_relation(邀请金币已下线,不写金币流水)
|
||||||
```
|
```
|
||||||
|
|
||||||
手动填码这条:B 在邀请页输码 → `InviteRepository.bindManual()` → `POST /bind { channel="manual" }` → 同一个 `bind()`。
|
手动填码这条:B 在邀请页输码 → `InviteRepository.bindManual()` → `POST /bind { channel="manual" }` → 同一个 `bind()`。
|
||||||
@@ -52,20 +52,20 @@ B 安装并首启 App
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 端点 | `app/api/v1/invite.py`:`GET /api/v1/invite/me`(返回 `invite_code` + `share_url` + 战绩)、`POST /api/v1/invite/bind`(绑定,`channel` = `clipboard` / `manual`)。**均需 Bearer 鉴权**。 |
|
| 端点 | `app/api/v1/invite.py`:`GET /api/v1/invite/me`(返回 `invite_code` + `share_url` + 战绩)、`POST /api/v1/invite/bind`(绑定,`channel` = `clipboard` / `manual`)。**均需 Bearer 鉴权**。 |
|
||||||
| share_url 构造 | `invite.py` 的 `my_invite`:`settings.INVITE_LANDING_URL + "?ref=" + code`。`INVITE_LANDING_URL` 在 `app/core/config.py`(默认 `https://app-api.shaguabijia.com/media/dl.html`)。 |
|
| share_url 构造 | `invite.py` 的 `my_invite`:`settings.INVITE_LANDING_URL + "?ref=" + code`。`INVITE_LANDING_URL` 在 `app/core/config.py`(默认 `https://app-api.shaguabijia.com/media/dl.html`)。 |
|
||||||
| 业务逻辑 | `app/repositories/invite.py`:`ensure_code`(懒生成 6 位邀请码,去混淆字符集,唯一约束碰撞则换码)/ `resolve_inviter`(邀请码→邀请人,大小写不敏感)/ `bind`(下面详述)/ `get_stats`(已邀人数 + 累计金币)。 |
|
| 业务逻辑 | `app/repositories/invite.py`:`ensure_code`(懒生成 6 位邀请码,去混淆字符集,唯一约束碰撞则换码)/ `resolve_inviter`(邀请码→邀请人,大小写不敏感)/ `bind`(下面详述)/ `get_stats`(已邀人数 + 兼容累计金币字段,当前恒为 0)。 |
|
||||||
| 数据模型 | `app/models/invite.py` 的 `InviteRelation`(`inviter_user_id` / `invitee_user_id` / `channel` / `status` / `inviter_coin` / `invitee_coin` / `created_at`)+ `app/models/user.py` 的 `User.invite_code` 列。 |
|
| 数据模型 | `app/models/invite.py` 的 `InviteRelation`(`inviter_user_id` / `invitee_user_id` / `channel` / `status` / `inviter_coin` / `invitee_coin` / `created_at`)+ `app/models/user.py` 的 `User.invite_code` 列。 |
|
||||||
| 迁移 | `alembic/versions/invite_code_and_relation.py`:给 `user` 加 `invite_code`(唯一索引)+ 建 `invite_relation` 表。`down_revision = 11a1d08c6f55`。 |
|
| 迁移 | `alembic/versions/invite_code_and_relation.py`:给 `user` 加 `invite_code`(唯一索引)+ 建 `invite_relation` 表。`down_revision = 11a1d08c6f55`。 |
|
||||||
| 收发模型 | `app/schemas/invite.py`:`InviteInfoOut` / `BindInviteIn` / `BindInviteOut`。 |
|
| 收发模型 | `app/schemas/invite.py`:`InviteInfoOut` / `BindInviteIn` / `BindInviteOut`。 |
|
||||||
| 奖励常量 | `app/core/rewards.py`:`INVITE_INVITER_COINS` / `INVITE_INVITEE_COINS`(各 10000 = 1 元)、`INVITE_NEW_USER_WINDOW_HOURS`(72)。 |
|
| 新人窗口 | `app/core/rewards.py`:`INVITE_NEW_USER_WINDOW_HOURS`(72)。邀请金币已下线,不再配置邀请金币常量。 |
|
||||||
|
|
||||||
**`bind()` 的四道防线(防重复 / 防刷,看 `repositories/invite.py`):**
|
**`bind()` 的四道防线(防重复 / 防刷,看 `repositories/invite.py`):**
|
||||||
|
|
||||||
1. **被邀请人唯一**:`invitee_user_id` 唯一约束 → 一个 B 只能被绑一次(幂等键,重复返回 `already_bound`,不重复发奖)。
|
1. **被邀请人唯一**:`invitee_user_id` 唯一约束 → 一个 B 只能被绑一次(幂等键,重复返回 `already_bound`,不重复绑定)。
|
||||||
2. **自邀屏蔽**:`inviter == invitee` → `self_invite`。
|
2. **自邀屏蔽**:`inviter == invitee` → `self_invite`。
|
||||||
3. **新人闸**:`_is_new_user`(B 的 `created_at` 在 `INVITE_NEW_USER_WINDOW_HOURS` = 72h 内)才发奖,挡存量老用户互相填码薅羊毛 → 否则 `not_eligible`。
|
3. **新人闸**:`_is_new_user`(B 的 `created_at` 在 `INVITE_NEW_USER_WINDOW_HOURS` = 72h 内)才生效,挡存量老用户互相填码刷关系 → 否则 `not_eligible`。
|
||||||
4. **手机号唯一**(天然限量):每个 B = 一个真实手机号账号。
|
4. **手机号唯一**(天然限量):每个 B = 一个真实手机号账号。
|
||||||
|
|
||||||
发金币复用 `repositories/wallet.py` 的 `grant_coins`,与建关系记录在**同一事务**提交,保证"建关系 + 双方加金币"原子。
|
邀请金币已下线:`bind()` 只记录绑定关系,不再写 `coin_transaction`;响应里的金币字段保留兼容旧客户端,当前恒为 0。
|
||||||
|
|
||||||
### 3.2 前端(shaguabijia-app-android)
|
### 3.2 前端(shaguabijia-app-android)
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ B 安装并首启 App
|
|||||||
### 4.4 测试硬约束 / 坑(都是机制,不是 bug)
|
### 4.4 测试硬约束 / 坑(都是机制,不是 bug)
|
||||||
|
|
||||||
- **B 必须用新手机号**:`invitee_user_id` 唯一,一个 B 只能绑一次;反复测要换号(或手删 `invite_relation` 那行 + 回滚金币)。
|
- **B 必须用新手机号**:`invitee_user_id` 唯一,一个 B 只能绑一次;反复测要换号(或手删 `invite_relation` 那行 + 回滚金币)。
|
||||||
- **72h 新人闸**:B 注册后 72 小时内绑才发奖(刚注册肯定满足)。
|
- **72h 新人闸**:B 注册后 72 小时内绑定才生效(刚注册肯定满足)。
|
||||||
- **A ≠ B**:自邀被屏蔽。
|
- **A ≠ B**:自邀被屏蔽。
|
||||||
- **B 从点下载到首启 App 之间别复制别的东西**:剪贴板会被覆盖 → 归因丢(剪贴板 deferred deeplink 的固有脆弱性)。
|
- **B 从点下载到首启 App 之间别复制别的东西**:剪贴板会被覆盖 → 归因丢(剪贴板 deferred deeplink 的固有脆弱性)。
|
||||||
- **笔记本 IP 别变**:debug 包把 `BASE_URL` 的 IP 烧死在编译期,DHCP 一换就连不上 → 给笔记本固定个 LAN IP。
|
- **笔记本 IP 别变**:debug 包把 `BASE_URL` 的 IP 烧死在编译期,DHCP 一换就连不上 → 给笔记本固定个 LAN IP。
|
||||||
|
|||||||
+7771
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* SGApi —— H5 调 app-server 后端的薄封装。
|
||||||
|
*
|
||||||
|
* 同源:H5 由 app-server 的 /media/h5/ 托管,后端在 /api/v1,同 host:port → 用相对路径、无 CORS。
|
||||||
|
* 鉴权:JWT Bearer。token 经 SGBridge.getToken() 从原生取(原生持登录态);浏览器调试走 bridge 的 mock token。
|
||||||
|
* 401:交原生拉登录(requestLogin)兜底,本次请求按失败 reject;正式的 refresh 重试策略阶段2 再补。
|
||||||
|
*
|
||||||
|
* 依赖 shared/bridge.js 先加载(取 token)。
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var BASE = '/api/v1';
|
||||||
|
|
||||||
|
function authHeaders() {
|
||||||
|
var t = (global.SGBridge && global.SGBridge.getToken()) || '';
|
||||||
|
var h = { 'Content-Type': 'application/json' };
|
||||||
|
if (t) h['Authorization'] = 'Bearer ' + t;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle(res) {
|
||||||
|
if (res.status === 401) {
|
||||||
|
// 未授权:拉原生登录(异步),本次请求按失败处理,调用方自行决定是否重试
|
||||||
|
if (global.SGBridge) global.SGBridge.requestLogin();
|
||||||
|
return Promise.reject(new Error('unauthorized'));
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
return res.text().then(function (t) {
|
||||||
|
return Promise.reject(new Error('http ' + res.status + ' ' + t));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 204 / 空体兜底
|
||||||
|
return res.text().then(function (t) { return t ? JSON.parse(t) : null; });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/v1<path>。path 以 / 开头,如 '/savings/battle'。 */
|
||||||
|
function apiGet(path) {
|
||||||
|
return fetch(BASE + path, { method: 'GET', headers: authHeaders() }).then(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/v1<path>,body 自动 JSON 序列化。 */
|
||||||
|
function apiPost(path, body) {
|
||||||
|
return fetch(BASE + path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders(),
|
||||||
|
body: JSON.stringify(body || {}),
|
||||||
|
}).then(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
global.SGApi = { base: BASE, get: apiGet, post: apiPost };
|
||||||
|
})(window);
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* SGBridge —— 傻瓜比价 H5 ↔ Android 原生 的桥。
|
||||||
|
*
|
||||||
|
* 背景:四个主 tab(首页/福利/记录/我的)由原生 Compose 改造为 WebView 加载本工程 H5。
|
||||||
|
* H5 只负责"画 + 取后端数据";凡需要原生能力(登录态 / 跳转 / 跳外卖 App / 比价领券 /
|
||||||
|
* 权限 / 定位 / Toast / 激励视频),一律经本桥调用原生。
|
||||||
|
*
|
||||||
|
* 协议两个方向:
|
||||||
|
* ① H5 → 原生:Android 端 WebView.addJavascriptInterface(obj, "SGBridgeNative")。
|
||||||
|
* obj 方法都是【同步】:查询类返回 String(JSON 或纯串),动作类无返回。
|
||||||
|
* ② 原生 → H5:原生执行 evaluateJavascript("window.SGBridge._emit('<event>', '<json>')")。
|
||||||
|
* 事件:onAuthChange(登录态变) / onBalanceChange(余额变) / onSigninChange(签到态变) / onResume(回前台刷新)。
|
||||||
|
*
|
||||||
|
* 离线兜底:浏览器里(无 SGBridgeNative)走 MOCK,便于不装 App 直接在本地 server 调样式 / 渲染。
|
||||||
|
* 与原生实现对应:见 shaguabijia-app-android 的 SGBridge.kt(方法名逐个对齐本文件)。
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var native = global.SGBridgeNative || null;
|
||||||
|
var hasNative = !!native;
|
||||||
|
|
||||||
|
// ---- 离线 MOCK(仅无原生时生效,便于浏览器调试渲染;真机一律走 native) ----
|
||||||
|
var MOCK = {
|
||||||
|
authState: { loggedIn: true, userId: 1, nickname: '冰', avatarUrl: '', phone: '188****8888' },
|
||||||
|
token: 'mock-token-for-browser-debug',
|
||||||
|
deviceId: 'browser-debug-device',
|
||||||
|
appVersion: '0.0.0-debug',
|
||||||
|
};
|
||||||
|
|
||||||
|
function safeParse(s, fallback) {
|
||||||
|
try { return s ? JSON.parse(s) : fallback; } catch (e) { return fallback; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 查询类(同步返回) ======
|
||||||
|
|
||||||
|
/** 当前登录态 + 用户基本信息 → {loggedIn, userId, nickname, avatarUrl, phone}。 */
|
||||||
|
function getAuthState() {
|
||||||
|
if (hasNative && native.getAuthState) return safeParse(native.getAuthState(), { loggedIn: false });
|
||||||
|
return MOCK.authState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后端鉴权用的 access token(空串=未登录)。原生持登录态,H5 调后端前取它拼 Bearer。 */
|
||||||
|
function getToken() {
|
||||||
|
if (hasNative && native.getToken) return native.getToken() || '';
|
||||||
|
return MOCK.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设备唯一标识(心跳 / 领券状态查询等用)。 */
|
||||||
|
function getDeviceId() {
|
||||||
|
if (hasNative && native.getDeviceId) return native.getDeviceId() || '';
|
||||||
|
return MOCK.deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** App 版本号。 */
|
||||||
|
function getAppVersion() {
|
||||||
|
if (hasNative && native.getAppVersion) return native.getAppVersion() || '';
|
||||||
|
return MOCK.appVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已安装的目标电商/外卖 App 包名数组(原生 InstalledApps 探测)。H5 选平台弹窗据此判真实装机态。 */
|
||||||
|
function getInstalledApps() {
|
||||||
|
if (hasNative && native.getInstalledApps) return safeParse(native.getInstalledApps(), []);
|
||||||
|
// MOCK(浏览器无原生):给主流已装,便于本地预览选平台弹窗正常显示"有"。
|
||||||
|
return ['com.sankuai.meituan', 'com.taobao.taobao', 'com.jingdong.app.mall', 'me.ele'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 今日是否已领券(置灰「去领取」→「去查看」)。原生读 CompareButtonState(SP 按天);无桥默认 false。 */
|
||||||
|
function getCouponClaimedToday() {
|
||||||
|
if (hasNative && native.getCouponClaimedToday) return !!native.getCouponClaimedToday();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 动作类(无返回;异步结果走事件) ======
|
||||||
|
|
||||||
|
/** 跳原生页。route 取值对齐安卓 Routes(invite / settings / feedback / withdrawal / compareRecords / reportFlow / guideVideo / compareResult / coinHistory / cashHistory / welfareRules ...)。 */
|
||||||
|
function navigate(route) {
|
||||||
|
if (hasNative && native.navigate) native.navigate(route);
|
||||||
|
else console.log('[SGBridge mock] navigate →', route);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拉起极光一键登录。结果异步经 onAuthChange 事件回来(不在此函数返回)。 */
|
||||||
|
function requestLogin() {
|
||||||
|
if (hasNative && native.requestLogin) native.requestLogin();
|
||||||
|
else console.log('[SGBridge mock] requestLogin');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 原生居中 Toast。 */
|
||||||
|
function toast(msg) {
|
||||||
|
if (hasNative && native.toast) native.toast(String(msg));
|
||||||
|
else console.log('[SGBridge mock] toast →', msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 跳美团/外卖 App(deeplink 优先;空则原生按包名启动,未装可跳应用商店)。 */
|
||||||
|
function openMeituan(deeplink) {
|
||||||
|
if (hasNative && native.openMeituan) native.openMeituan(deeplink || '');
|
||||||
|
else console.log('[SGBridge mock] openMeituan →', deeplink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 触发 agent 比价流程(原生起无障碍引擎)。 */
|
||||||
|
function startCompare() {
|
||||||
|
if (hasNative && native.startCompare) native.startCompare();
|
||||||
|
else console.log('[SGBridge mock] startCompare');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 触发一键领券(原生先校验悬浮窗/无障碍权限,再起前台服务)。platforms: string[]。 */
|
||||||
|
function startCouponClaim(platforms) {
|
||||||
|
var json = JSON.stringify(platforms || []);
|
||||||
|
if (hasNative && native.startCouponClaim) native.startCouponClaim(json);
|
||||||
|
else console.log('[SGBridge mock] startCouponClaim →', json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按候选包名拉起对应平台 App。照原生 HomePicker 点击跳转:原生在该平台一组候选包里取首个可
|
||||||
|
* getLaunchIntentForPackage 的拉起(NEW_TASK|CLEAR_TASK 冷启到平台首页)。packages: string[](一个平台一组候选包,任一可拉即拉)。 */
|
||||||
|
function launchApp(packages) {
|
||||||
|
var json = JSON.stringify(packages || []);
|
||||||
|
if (hasNative && native.launchApp) native.launchApp(json);
|
||||||
|
else console.log('[SGBridge mock] launchApp →', json);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 原生 → H5 事件总线 ======
|
||||||
|
|
||||||
|
var listeners = {}; // event → [fn]
|
||||||
|
|
||||||
|
/** 订阅原生事件。返回取消订阅函数。 */
|
||||||
|
function on(event, fn) {
|
||||||
|
(listeners[event] || (listeners[event] = [])).push(fn);
|
||||||
|
return function off() {
|
||||||
|
listeners[event] = (listeners[event] || []).filter(function (f) { return f !== fn; });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 供原生回调:window.SGBridge._emit('onAuthChange', '{...}')。payload 可为 JSON 串或对象。 */
|
||||||
|
function _emit(event, payload) {
|
||||||
|
var data = typeof payload === 'string' ? safeParse(payload, payload) : payload;
|
||||||
|
(listeners[event] || []).forEach(function (fn) {
|
||||||
|
try { fn(data); } catch (e) { console.error('[SGBridge] listener error', event, e); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
global.SGBridge = {
|
||||||
|
hasNative: hasNative,
|
||||||
|
// 查询
|
||||||
|
getAuthState: getAuthState,
|
||||||
|
getToken: getToken,
|
||||||
|
getDeviceId: getDeviceId,
|
||||||
|
getAppVersion: getAppVersion,
|
||||||
|
getInstalledApps: getInstalledApps,
|
||||||
|
getCouponClaimedToday: getCouponClaimedToday,
|
||||||
|
// 动作
|
||||||
|
navigate: navigate,
|
||||||
|
requestLogin: requestLogin,
|
||||||
|
toast: toast,
|
||||||
|
openMeituan: openMeituan,
|
||||||
|
startCompare: startCompare,
|
||||||
|
startCouponClaim: startCouponClaim,
|
||||||
|
launchApp: launchApp,
|
||||||
|
// 事件
|
||||||
|
on: on,
|
||||||
|
_emit: _emit,
|
||||||
|
};
|
||||||
|
})(window);
|
||||||
@@ -305,11 +305,12 @@ def test_feed_reward_grants_by_10_second_units(client) -> None:
|
|||||||
"duration_seconds": 30,
|
"duration_seconds": 30,
|
||||||
"adn": "pangle",
|
"adn": "pangle",
|
||||||
"slot_id": "slot_feed",
|
"slot_id": "slot_feed",
|
||||||
|
"display_coin": 4,
|
||||||
}
|
}
|
||||||
r = client.post("/api/v1/ad/feed-reward", json=payload, headers=_auth(token))
|
r = client.post("/api/v1/ad/feed-reward", json=payload, headers=_auth(token))
|
||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
body = r.json()
|
body = r.json()
|
||||||
expected = sum(calculate_ad_reward_coin("200", i) for i in range(1, 4))
|
expected = 4
|
||||||
assert body["granted"] is True
|
assert body["granted"] is True
|
||||||
assert body["status"] == "granted"
|
assert body["status"] == "granted"
|
||||||
assert body["unit_count"] == 3
|
assert body["unit_count"] == 3
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ def test_dashboard_overview(admin_client: TestClient, admin_token: str) -> None:
|
|||||||
assert data["users"]["total"] >= 1
|
assert data["users"]["total"] >= 1
|
||||||
assert data["coins"]["granted_total"] >= 5000
|
assert data["coins"]["granted_total"] >= 5000
|
||||||
assert "success_rate" in data["comparison"]
|
assert "success_rate" in data["comparison"]
|
||||||
assert data["cps"]["available"] is False
|
assert data["cps"]["available"] is True
|
||||||
|
assert "meituan_order_count" in data["cps"]
|
||||||
|
|
||||||
|
|
||||||
def test_user_list_and_detail(admin_client: TestClient, admin_token: str) -> None:
|
def test_user_list_and_detail(admin_client: TestClient, admin_token: str) -> None:
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ def _food_payload(trace_id: str) -> dict:
|
|||||||
"skipped_dish_names": ["黑牛肉卷"],
|
"skipped_dish_names": ["黑牛肉卷"],
|
||||||
"total_dish_count": 3,
|
"total_dish_count": 3,
|
||||||
"information": "在美团找到同店,到手价 ¥123.50",
|
"information": "在美团找到同店,到手价 ¥123.50",
|
||||||
|
"total_ms": 12345,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -71,6 +72,8 @@ def test_report_and_derive(client) -> None:
|
|||||||
assert d["information"] == "在美团找到同店,到手价 ¥123.50"
|
assert d["information"] == "在美团找到同店,到手价 ¥123.50"
|
||||||
assert d["store_name"] == "海底捞(朝阳店)"
|
assert d["store_name"] == "海底捞(朝阳店)"
|
||||||
assert d["total_dish_count"] == 3
|
assert d["total_dish_count"] == 3
|
||||||
|
assert d["total_ms"] == 12345
|
||||||
|
assert d["raw_payload"]["total_ms"] == 12345
|
||||||
assert d["skipped_dish_count"] == 1
|
assert d["skipped_dish_count"] == 1
|
||||||
assert d["skipped_dish_names"] == ["黑牛肉卷"]
|
assert d["skipped_dish_names"] == ["黑牛肉卷"]
|
||||||
assert len(d["comparison_results"]) == 3
|
assert len(d["comparison_results"]) == 3
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ def call_raw(path: str, body_obj: dict) -> dict:
|
|||||||
}
|
}
|
||||||
url = f"{settings.MT_CPS_HOST}{path}"
|
url = f"{settings.MT_CPS_HOST}{path}"
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
resp = httpx.post(url, content=body, headers=headers, timeout=settings.MT_CPS_TIMEOUT_SEC)
|
# trust_env=False: 美团是国内域名,强制直连绕开本机代理(代理会掐断 TLS 握手,报 SSL EOF)
|
||||||
|
resp = httpx.post(url, content=body, headers=headers, timeout=settings.MT_CPS_TIMEOUT_SEC, trust_env=False)
|
||||||
ms = int((time.time() - t0) * 1000)
|
ms = int((time.time() - t0) * 1000)
|
||||||
try:
|
try:
|
||||||
j = resp.json()
|
j = resp.json()
|
||||||
|
|||||||
Reference in New Issue
Block a user