美团 feed 改用离线库 + 三 tab 降级兜底(替换 main 的实时版 feed) #88
+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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|||||||
@@ -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