Compare commits

..

8 Commits

Author SHA1 Message Date
no_gen_mu aadade72de 0.01 元调试提现放行小额 + 已邀请好友数改按已比价口径
create_withdraw 加 allow_sub_min:仅 endpoint 在 skip_review 且非 prod(debug包+非生产双闸)时放行低于最低额的小额,生产恒按最低额校验。邀请 get_stats 的已邀请好友数改为只数 compare_reward_granted=True(完成比价、已发奖励)的好友,使人数×2元=累计提现+可提现余额恒等式成立,不再出现已邀请1、可提现0。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:23:12 +08:00
no_gen_mu afd92289f2 官网下载 APK 直链:强制下载头 + 发布脚本
落地页「官网下载」指向 /media/shaguabijia.apk,但该文件靠手动 cp、且 StaticFiles 给 .apk 的 Content-Type 不对又无 attachment 头,部分国产浏览器不触发下载转甩应用市场。新增 download_apk 路由(挂在静态目录之前),显式回 application/vnd.android.package-archive + Content-Disposition:attachment 强制下载,文件缺失回 404。新增 scripts/publish_apk.sh 把「编 release 包 + 放到 data/media/shaguabijia.apk」固化成一条命令。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:22:54 +08:00
no_gen_mu 40dbe504c3 fix(dl): 落地页底图 + logo 纳入 git → 修生产 /media 404 毛坯
线上 dl.html 的 HTML 早已是完善版且已部署,但它引用的两张静态图
(coupon-page-bg.png 底图含优惠券卡/跨平台比价价格卡、sb-brand.png logo)
被 .gitignore 的 `data/media/*` 规则挡在库外,从未随部署进生产 /media,
导致线上两图均 404(返回 22 字节 {"detail":"Not Found"})→ 页面回退裸黄底=毛坯。

修复:照 dl.html / taobao_landing.jpg 既有白名单写法,把这两张图加进
.gitignore 例外并入库。文件 md5 与原型一致
(coupon-page-bg f2adf28a / sb-brand e5b6e004)。部署本分支后线上即完整。

无代码/HTML 改动(dl.html 本就对齐原型)。对应 6-29 UI 对齐 #10。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:36:16 +08:00
no_gen_mu 6d4cbc8a2f fix(dl): 官网下载改直链下APK + 注释微信引导「我知道了」按钮
落地页 data/media/dl.html 两处改动:
1. 「官网下载」按钮(dlbtn2)不再共用应用商店逻辑,安卓浏览器内直接
   跳 APK 直链(location.origin + /media/shaguabijia.apk) 触发系统下载弹窗。
   微信引导/iOS提示/邀请码剪贴板归因(copyInviteCode)均保留;
   「应用商店下载」(dlbtn)不变。
2. 注释(不删)微信引导蒙层底部「我知道了」关闭按钮及其事件绑定,
   保留可随时取消注释恢复。

注:两处均需部署到生产 app-api.shaguabijia.com 才生效,本地改动不影响线上。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 21:16:46 +08:00
wuqi c0b67fd879 feat(launch_confirm): 新增启动确认样本列表内部接口(供 pricebot distill 回读) (#91)
补齐「启动确认窗自进化闭环」里 app-server 这一侧的读路径:pricebot 的 launch_confirm
LLM 兜底成功样本会 server→server 落到 app-server 的 launch_confirm_sample 表;本提交
新增「样本列表查询」内部接口, 供 pricebot 的 scripts/distill_launch_confirm.py 回读这些
样本、按「宿主包 × 文案变体」聚合沉淀出候选规则。

- app/api/internal/launch_confirm.py: 新增样本列表内部接口(server→server, 不走用户 JWT)
- app/repositories/launch_confirm_sample.py: 样本列表查询
- app/schemas/launch_confirm_sample.py: 列表响应 schema

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Reviewed-on: #91
Co-authored-by: wuqi <wuqi@wonderable.ai>
Co-committed-by: wuqi <wuqi@wonderable.ai>
2026-06-28 21:40:42 +08:00
xiebing 2ed62c789f feat(h5): 我的页改 H5(mine 页 + shared bridge/api) (#89)
- h5/mine/index.html: 个人中心「我的页」H5 实现
- h5/shared/bridge.js, api.js: H5↔客户端桥与 API 封装(mine 依赖)

Reviewed-on: #89
Co-authored-by: xiebing <xiebing@wonderable.ai>
Co-committed-by: xiebing <xiebing@wonderable.ai>
2026-06-28 14:16:32 +08:00
chenshuobo 1548406f29 美团 feed 改用离线库 + 三 tab 降级兜底(替换 main 的实时版 feed) (#88)
Co-authored-by: lowmaster-chen <1119780489@qq.com>
Co-authored-by: chenshuobo <1119780489@qq.com>
Reviewed-on: #88
Co-authored-by: chenshuobo <chenshuobo@wonderable.ai>
Co-committed-by: chenshuobo <chenshuobo@wonderable.ai>
2026-06-28 10:38:31 +08:00
guke b7b958ed58 perf(pricebot): 透传复用共享 httpx 单例,免每请求重建 SSL 上下文 + 绕过进程代理 (#87)
coupon/compare 透传原先每请求 async with httpx.AsyncClient(...) 新建:
每次重建 SSL 上下文(加载 certifi CA)实测 ~1s+,而 pricebot 是纯 HTTP
透传根本用不到 TLS;且 trust_env 默认 True 会把 http://localhost:8000
经进程代理(Clash)再绕几秒。

抽 app/core/pricebot_client.py 共享单例:trust_env=False 直连,lifespan
启动预热(SSL 一次性成本付在启动)、关停 aclose;timeout 下放到 .post()
保留 coupon 30s / compare 60s 差异。keep-alive 复用 TCP,每帧降到个位数 ms。

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #87
Co-authored-by: guke <guke@wonderable.ai>
Co-committed-by: guke <guke@wonderable.ai>
2026-06-28 09:36:31 +08:00
29 changed files with 8714 additions and 58 deletions
+5 -2
View File
@@ -23,14 +23,17 @@ dist/
*.db-journal
*.db-shm
*.db-wal
# data/ 整体不入库(运行时数据/上传文件/大二进制)。例外:邀请落地页 dl.html——
# 它既是生产落地页又是本地测试资产,纳入 git 便于同事一致测试
# data/ 整体不入库(运行时数据/上传文件/大二进制)。例外:邀请落地页 dl.html 及其
# 引用的静态插画(coupon-page-bg.png 底图 + sb-brand.png logo)——既是生产落地页资产
# 又是本地测试资产,纳入 git 便于同事一致测试、且随部署进生产 /media(否则线上 404→落地页毛坯)。
# (见 docs/邀请功能-实现原理与本地测试.md)。其余(avatars/ / *.apk / app.db 等)仍忽略。
data/*
!data/media/
data/media/*
!data/media/dl.html
!data/media/taobao_landing.jpg
!data/media/coupon-page-bg.png
!data/media/sb-brand.png
secrets/*
!secrets/.gitkeep
+338 -13
View File
@@ -5,7 +5,8 @@ user.last_login_at / comparison_record.status / withdraw_order.status)要加索
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from datetime import date, datetime, time, timedelta, timezone
from decimal import Decimal, InvalidOperation
from sqlalchemy import func, select
from sqlalchemy.orm import Session
@@ -13,12 +14,27 @@ from sqlalchemy.orm import Session
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.models.ad_reward import AdRewardRecord
from app.models.comparison import ComparisonRecord
from app.models.coupon_state import CouponPromptEngagement
from app.models.cps_order import CpsOrder
from app.models.feedback import Feedback
from app.models.savings import SavingsRecord
from app.models.signin import SigninBoostRecord, SigninRecord
from app.models.user import User
from app.models.wallet import CoinTransaction, WithdrawOrder
_BEIJING = timezone(timedelta(hours=8))
COUPON_REWARD_BIZ_TYPES = ("reward_video", "ad_reward", "coupon", "coupon_reward")
COMPARISON_REWARD_BIZ_TYPES = ("comparison", "compare_reward", "comparison_reward")
EXCLUDED_REWARD_BIZ_TYPES = ("invite_inviter", "invite_invitee", "admin_grant")
UNCLASSIFIED_FEED_BIZ_TYPES = ("feed_ad_reward",)
REGULAR_TASK_EXCLUDED_BIZ_TYPES = (
*COUPON_REWARD_BIZ_TYPES,
*COMPARISON_REWARD_BIZ_TYPES,
*EXCLUDED_REWARD_BIZ_TYPES,
*UNCLASSIFIED_FEED_BIZ_TYPES,
)
MEITUAN_CPS_INVALID_STATUSES = ("4", "5")
MEITUAN_CPS_SETTLED_STATUS = "6"
def _beijing_today_start_utc() -> datetime:
@@ -31,30 +47,87 @@ def _beijing_today_start_utc() -> datetime:
def today_dau(db: Session) -> int:
"""今日活跃用户数(DAU):北京时区今天 0 点后登录过(last_login_at)。
大盘与广告收益报表共用此口径,单一来源避免漂移
⚠️ last_login_at 是单值字段(只存最后一次登录时刻),故只能算「今日」,
无法回溯历史某天的 DAU——调用方按此约束决定历史区间是否展示。
广告收益报表复用这个函数;历史窗口 DAU 由 dashboard_overview 的 period 口径另算
"""
today_start = _beijing_today_start_utc()
return db.execute(
select(func.count(User.id)).where(User.last_login_at >= today_start)
).scalar_one()
return int(
db.execute(select(func.count(User.id)).where(User.last_login_at >= today_start)).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()
period_from, period_to = _normalize_period(date_from, date_to)
start_utc, end_utc, start_local, end_local = _period_bounds(period_from, period_to)
def _count(model, *conds) -> int:
stmt = select(func.count(model.id))
if conds:
stmt = stmt.where(*conds)
return db.execute(stmt).scalar_one()
return int(db.execute(stmt).scalar_one())
def _sum(col, *conds) -> int:
stmt = select(func.coalesce(func.sum(col), 0))
if conds:
stmt = stmt.where(*conds)
return db.execute(stmt).scalar_one()
return int(db.execute(stmt).scalar_one())
def _user_id_set(stmt) -> set[int]:
return {int(v) for v in db.execute(stmt).scalars().all() if v is not None}
# ===== 用户 =====
by_status = dict(
@@ -74,6 +147,204 @@ def dashboard_overview(db: Session) -> dict:
comparison_total = _count(ComparisonRecord)
comparison_success = _count(ComparisonRecord, ComparisonRecord.status == "success")
success_rate = round(comparison_success / comparison_total, 4) if comparison_total else 0.0
period_comparison_conds = (
ComparisonRecord.created_at >= start_local,
ComparisonRecord.created_at < end_local,
)
period_comparison_total = _count(ComparisonRecord, *period_comparison_conds)
period_comparison_success = _count(
ComparisonRecord,
*period_comparison_conds,
ComparisonRecord.status == "success",
)
period_comparison_success_rate = (
round(period_comparison_success / period_comparison_total, 4)
if period_comparison_total
else 0.0
)
period_saved_positive_count = _count(
ComparisonRecord,
*period_comparison_conds,
ComparisonRecord.status == "success",
ComparisonRecord.saved_amount_cents > 0,
)
period_saved_positive_sum = _sum(
ComparisonRecord.saved_amount_cents,
*period_comparison_conds,
ComparisonRecord.status == "success",
ComparisonRecord.saved_amount_cents > 0,
)
period_avg_saved_cents = (
round(period_saved_positive_sum / period_saved_positive_count)
if period_saved_positive_count
else None
)
period_avg_duration_ms = db.execute(
select(func.avg(ComparisonRecord.total_ms)).where(
*period_comparison_conds,
ComparisonRecord.total_ms.is_not(None),
ComparisonRecord.total_ms > 0,
)
).scalar_one()
period_avg_duration_ms = (
round(float(period_avg_duration_ms))
if period_avg_duration_ms is not None
else None
)
ordered_exists = (
select(SavingsRecord.id)
.where(
SavingsRecord.user_id == ComparisonRecord.user_id,
SavingsRecord.source == "compare",
SavingsRecord.shop_name.is_not(None),
SavingsRecord.shop_name == ComparisonRecord.store_name,
)
.exists()
)
period_ordered_count = _count(
ComparisonRecord,
*period_comparison_conds,
ComparisonRecord.store_name.is_not(None),
ordered_exists,
)
# ===== 日期窗口用户 =====
period_new_user_ids = _user_id_set(
select(User.id).where(User.created_at >= start_utc, User.created_at < end_utc)
)
login_user_ids = _user_id_set(
select(User.id).where(User.last_login_at >= start_utc, User.last_login_at < end_utc)
)
compare_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 {
"users": {
@@ -132,7 +403,61 @@ def dashboard_overview(db: Session) -> dict:
"success": comparison_success,
"success_rate": success_rate,
},
"feedback": {"new": _count(Feedback, Feedback.status.in_(("pending", "new")))},
# CPS 收入数据源未接(referral-link 只换链接,转化/佣金未回收)→ 前端显示"待接入"。
"cps": {"available": False, "note": "CPS 转化数据未接入(P2)"},
"period": {
"date_from": period_from,
"date_to": period_to,
"users": {
"new": len(period_new_user_ids),
"active": len(period_active_user_ids),
"retained_new_users": len(period_retained_new_user_ids),
"retention_rate": period_retention_rate,
"retention_note": (
"近似口径:登录(last_login_at)+已上报比价记录+领券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,
},
}
+58 -5
View File
@@ -7,7 +7,7 @@
from __future__ import annotations
import time
from datetime import datetime, timedelta, timezone
from datetime import date as _date, datetime, time as _dt_time, timedelta, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
@@ -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="拉取美团订单对账")
def reconcile_orders(
request: Request,
admin: Annotated[AdminUser, Depends(require_role("finance"))],
db: AdminDb,
days: Annotated[int, Query(ge=1, le=90)] = 7,
date_from: Annotated[str | None, Query(description="起始日 YYYY-MM-DD")] = None,
date_to: Annotated[str | None, Query(description="结束日 YYYY-MM-DD")] = None,
days: Annotated[int, Query(ge=1, le=90, description="未传日期时默认拉近 N 天")] = 7,
sid: Annotated[str | None, Query(max_length=64)] = None,
query_time_type: Annotated[int, Query(ge=1, le=2)] = 1,
) -> 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:
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:
raise HTTPException(status_code=502, detail=f"美团拉单失败: {e}") from e
write_audit(
db, admin, action="cps.order.reconcile", target_type="cps_order", target_id=None,
detail={"days": days, "sid": sid, **result}, ip=get_client_ip(request), commit=True,
detail={
"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)
+11 -3
View File
@@ -1,7 +1,9 @@
"""admin 数据大盘(只读聚合)。"""
from __future__ import annotations
from fastapi import APIRouter, Depends
from datetime import date
from fastapi import APIRouter, Depends, Query
from app.admin.deps import AdminDb, get_current_admin
from app.admin.repositories import stats
@@ -15,5 +17,11 @@ router = APIRouter(
@router.get("/overview", response_model=DashboardOverview, summary="大盘核心指标")
def overview(db: AdminDb) -> DashboardOverview:
return DashboardOverview.model_validate(stats.dashboard_overview(db))
def overview(
db: AdminDb,
date_from: date | None = Query(None, description="北京时间自然日起始日 YYYY-MM-DD"),
date_to: date | None = Query(None, description="北京时间自然日结束日 YYYY-MM-DD"),
) -> DashboardOverview:
return DashboardOverview.model_validate(
stats.dashboard_overview(db, date_from=date_from, date_to=date_to)
)
+59
View File
@@ -1,6 +1,8 @@
"""admin 大盘 schemas(对应 stats.dashboard_overview 的嵌套结构)。"""
from __future__ import annotations
from datetime import date
from pydantic import BaseModel
@@ -38,6 +40,56 @@ class DashboardComparison(BaseModel):
success_rate: float
class DashboardPeriodUsers(BaseModel):
new: int
active: int
retained_new_users: int
retention_rate: float | None = None
retention_note: str
class DashboardPeriodComparison(BaseModel):
total: int
success: int
success_rate: float
ordered: int
average_duration_ms: int | None = None
average_saved_cents: int | None = None
class DashboardPeriodCoins(BaseModel):
granted_total: int
reward_video_coin_total: int = 0
feed_ad_coin_total: int = 0
signin_coin_total: int = 0
signin_boost_coin_total: int = 0
task_coin_total: int = 0
coupon_reward_coin_total: int = 0
comparison_reward_coin_total: int = 0
regular_task_coin_total: int = 0
class DashboardPeriodCash(BaseModel):
withdraw_success_cents: int
class DashboardTrendPoint(BaseModel):
date: date
active_users: int
new_users: int
comparisons: int
class DashboardPeriod(BaseModel):
date_from: date
date_to: date
users: DashboardPeriodUsers
comparison: DashboardPeriodComparison
coins: DashboardPeriodCoins
cash: DashboardPeriodCash
trend: list[DashboardTrendPoint] = []
class DashboardFeedback(BaseModel):
new: int
@@ -45,6 +97,12 @@ class DashboardFeedback(BaseModel):
class DashboardCps(BaseModel):
available: bool
note: str
meituan_order_count: int = 0
meituan_commission_cents: int = 0
meituan_hit_count: int = 0
meituan_miss_count: int = 0
meituan_unknown_rate_count: int = 0
meituan_hit_rate: float | None = None
class DashboardOverview(BaseModel):
@@ -52,5 +110,6 @@ class DashboardOverview(BaseModel):
coins: DashboardCoins
cash: DashboardCash
comparison: DashboardComparison
period: DashboardPeriod
feedback: DashboardFeedback
cps: DashboardCps
+33
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import hmac
import logging
from datetime import datetime, timedelta, timezone
from typing import Annotated
from fastapi import APIRouter, Header, HTTPException, status
@@ -21,6 +22,7 @@ from app.repositories import launch_confirm_sample as repo
from app.schemas.launch_confirm_sample import (
LaunchConfirmSampleIn,
LaunchConfirmSampleOut,
LaunchConfirmSampleRow,
)
logger = logging.getLogger("shagua.internal.launch_confirm")
@@ -61,3 +63,34 @@ def report_launch_confirm_sample(
payload.exec_success, payload.trace_id,
)
return LaunchConfirmSampleOut(id=sid)
@router.get(
"/launch-confirm-samples",
response_model=list[LaunchConfirmSampleRow],
summary="启动确认窗兜底样本列表(沉淀脚本 distill 读; server→server, 不走 JWT)",
)
def list_launch_confirm_samples(
db: DbSession,
x_internal_secret: Annotated[str | None, Header()] = None,
exec_success: bool | None = None,
host_package: str | None = None,
since_days: int | None = None,
limit: int = 1000,
) -> list[LaunchConfirmSampleRow]:
"""读样本供 pricebot 的 distill_launch_confirm.py 聚合沉淀回 PROFILES。
与上报端点同一把共享密钥;exec_success/host_package/since_days 均可选,limit 默认 1000。
"""
_check_secret(x_internal_secret)
since = None
if since_days and since_days > 0:
since = datetime.now(timezone.utc) - timedelta(days=since_days)
rows = repo.list_samples(
db,
exec_success=exec_success,
host_package=host_package,
since=since,
limit=limit,
)
return [LaunchConfirmSampleRow.model_validate(r) for r in rows]
+5 -4
View File
@@ -26,6 +26,7 @@ import httpx
from fastapi import APIRouter, HTTPException, Request, status
from app.core.config import settings
from app.core.pricebot_client import get_pricebot_client
from app.core.pricebot_router import pick_pricebot
logger = logging.getLogger("shagua.compare")
@@ -65,10 +66,10 @@ async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]:
)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(
url, content=raw, headers={"Content-Type": "application/json"}
)
client = get_pricebot_client()
resp = await client.post(
url, content=raw, headers={"Content-Type": "application/json"}, timeout=timeout
)
except httpx.RequestError as e:
logger.error("[pricebot] request failed: %s", e)
raise HTTPException(
+5 -4
View File
@@ -20,6 +20,7 @@ from fastapi.concurrency import run_in_threadpool
from app.api.deps import CurrentUser, DbSession
from app.core.config import settings
from app.core.pricebot_client import get_pricebot_client
from app.core.pricebot_router import pick_pricebot
from app.db.session import SessionLocal
from app.repositories import coupon_state as coupon_repo
@@ -141,10 +142,10 @@ async def coupon_step(
)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(
url, content=raw, headers={"Content-Type": "application/json"}
)
client = get_pricebot_client()
resp = await client.post(
url, content=raw, headers={"Content-Type": "application/json"}, timeout=timeout
)
except httpx.RequestError as e:
logger.error("[pricebot] request failed: %s", e)
raise HTTPException(
+3
View File
@@ -202,6 +202,9 @@ def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> Withdraw
order = crud_wallet.create_withdraw(
db, user.id, req.amount_cents, source=req.source,
user_name=req.user_name, out_bill_no=req.out_bill_no,
# 0.01 元调试提现:放行低于最低额的小额。双闸——客户端仅 debug 包在「0.01 元提现」开关开时
# 连同 skip_review 一起下发;服务端仅非 prod 才认。生产恒 False,最低额校验照常。
allow_sub_min=(req.skip_review and not settings.is_prod),
)
except crud_wallet.InvalidWithdrawAmountError as e:
raise HTTPException(
+35
View File
@@ -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
+38 -10
View File
@@ -2,18 +2,23 @@
通过 `uvicorn app.main:app --reload` 启动。
"""
from __future__ import annotations
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from app.api.internal.app_version import router as internal_app_version_router
from app.api.internal.launch_confirm import router as internal_launch_confirm_router
from app.api.internal.price import router as internal_price_router
from app.api.internal.store import router as internal_store_router
from app.api.v1.ad import router as ad_router
from app.api.v1.analytics import router as analytics_router
from app.api.v1.auth import router as auth_router
@@ -21,12 +26,8 @@ from app.api.v1.compare import router as compare_router
from app.api.v1.compare_milestone import router as compare_milestone_router
from app.api.v1.compare_record import router as compare_record_router
from app.api.v1.coupon import router as coupon_router
from app.api.v1.device import router as device_router
from app.api.v1.cps_redirect import router as cps_redirect_router
from app.api.internal.app_version import router as internal_app_version_router
from app.api.internal.launch_confirm import router as internal_launch_confirm_router
from app.api.internal.price import router as internal_price_router
from app.api.internal.store import router as internal_store_router
from app.api.v1.device import router as device_router
from app.api.v1.feedback import router as feedback_router
from app.api.v1.invite import router as invite_router
from app.api.v1.meituan import router as meituan_router
@@ -40,15 +41,16 @@ from app.api.v1.user import router as user_router
from app.api.v1.wallet import router as wallet_router
from app.api.v1.wxpay import router as wxpay_router
from app.core.config import settings
from app.core.heartbeat_monitor_worker import (
start_heartbeat_monitor,
stop_heartbeat_monitor,
)
from app.core.daily_exchange_worker import (
start_daily_exchange_worker,
stop_daily_exchange_worker,
)
from app.core.heartbeat_monitor_worker import (
start_heartbeat_monitor,
stop_heartbeat_monitor,
)
from app.core.logging import setup_logging
from app.core.pricebot_client import aclose_pricebot_client, get_pricebot_client
from app.core.withdraw_reconcile_worker import (
start_withdraw_reconcile_worker,
stop_withdraw_reconcile_worker,
@@ -68,6 +70,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
settings.APP_DEBUG,
settings.DATABASE_URL.split("://", 1)[0],
)
get_pricebot_client() # 预热透传 client:把建 SSL 上下文的一次性成本付在启动,首个领券请求即热
reconcile_task = start_withdraw_reconcile_worker()
heartbeat_task = start_heartbeat_monitor()
daily_exchange_task = start_daily_exchange_worker()
@@ -77,6 +80,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
await stop_heartbeat_monitor(heartbeat_task)
await stop_withdraw_reconcile_worker(reconcile_task)
await stop_daily_exchange_worker(daily_exchange_task)
await aclose_pricebot_client()
logger.info("shutting down")
@@ -134,6 +138,30 @@ app.include_router(cps_redirect_router)
# 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。
_media_root = Path(settings.MEDIA_ROOT)
_media_root.mkdir(parents=True, exist_ok=True)
# 官网下载的 APK 直链(落地页 dl.html「官网下载」按钮指向 /media/shaguabijia.apk)。
# 必须在 StaticFiles 挂载【之前】注册,否则被静态挂载吃掉。
# StaticFiles 给 .apk 的 Content-Type 不对、且无 attachment 头 → 部分国产浏览器不触发下载、转甩应用市场;
# 这里显式回 application/vnd.android.package-archive + Content-Disposition:attachment 强制浏览器下载。
# 文件由 scripts/publish_apk.sh 编 release 包后放到 data/media/shaguabijia.apk(*.apk 不入 git,需部署时放)。
_APK_PATH = _media_root / "shaguabijia.apk"
@app.get(f"{settings.MEDIA_URL_PREFIX}/shaguabijia.apk", tags=["meta"], include_in_schema=False)
def download_apk() -> FileResponse:
if not _APK_PATH.is_file():
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="安装包未就绪")
return FileResponse(
_APK_PATH,
media_type="application/vnd.android.package-archive",
filename="shaguabijia.apk",
headers={"Content-Disposition": 'attachment; filename="shaguabijia.apk"'},
)
app.mount(
settings.MEDIA_URL_PREFIX,
StaticFiles(directory=str(_media_root)),
+1
View File
@@ -19,6 +19,7 @@ from app.models.coupon_state import ( # noqa: F401
CouponDailyCompletion,
CouponPromptEngagement,
)
from app.models.cps_order import CpsOrder # noqa: F401
from app.models.feedback import Feedback # noqa: F401
from app.models.invite import InviteRelation # noqa: F401
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
+9 -1
View File
@@ -216,13 +216,21 @@ def try_reward_on_compare(db: Session, invitee_user_id: int) -> CompareRewardRes
def get_stats(db: Session, inviter_id: int) -> tuple[int, int]:
"""返回 (已成功邀请人数, 累计从邀请获得的金币)。
"已邀请好友数"口径 = 完成一次比价(已触发邀请奖励金)的被邀请人数,即 compare_reward_granted=True。
不再数"仅绑定未比价"的关系——否则会出现"已邀请 1、可提现余额 0"(好友下载登录但没比价),
与产品口径"已邀请好友数 × 2元 = 累计提现 + 可提现余额"对不上。过滤后该恒等式天然成立
(每个计入的好友都恰好发过 1 笔 2 元,钱要么在余额要么已提现)。
金币口径(inviter_coin 之和)自 v3 起恒 0(邀请人收益改走邀请奖励金,见 get_reward_stats /
try_reward_on_compare);保留返回位兼容旧响应字段 coins_earned。
"""
count = db.execute(
select(func.count())
.select_from(InviteRelation)
.where(InviteRelation.inviter_user_id == inviter_id)
.where(
InviteRelation.inviter_user_id == inviter_id,
InviteRelation.compare_reward_granted.is_(True),
)
).scalar_one()
coins = db.execute(
select(func.coalesce(func.sum(InviteRelation.inviter_coin), 0))
+26
View File
@@ -2,8 +2,10 @@
from __future__ import annotations
import logging
from datetime import datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.launch_confirm_sample import LaunchConfirmSample
@@ -33,3 +35,27 @@ def insert_sample(db: Session, payload: LaunchConfirmSampleIn) -> int:
db.commit()
db.refresh(row)
return row.id
def list_samples(
db: Session,
*,
exec_success: Optional[bool] = None,
host_package: Optional[str] = None,
since: Optional[datetime] = None,
limit: int = 1000,
) -> list[LaunchConfirmSample]:
"""按条件查样本(pricebot 的 distill_launch_confirm.py 沉淀工具读)。created_at 升序。
过滤项都可空:exec_success(只看放行成功的)/ host_package(只看某宿主包)/
since(>= created_at)。limit 兜底防一次拉爆全表。
"""
stmt = select(LaunchConfirmSample)
if exec_success is not None:
stmt = stmt.where(LaunchConfirmSample.exec_success == exec_success)
if host_package:
stmt = stmt.where(LaunchConfirmSample.host_package == host_package)
if since is not None:
stmt = stmt.where(LaunchConfirmSample.created_at >= since)
stmt = stmt.order_by(LaunchConfirmSample.created_at.asc()).limit(limit)
return list(db.execute(stmt).scalars().all())
+7 -1
View File
@@ -610,6 +610,7 @@ def create_withdraw(
source: str = "coin_cash",
user_name: str | None = None,
out_bill_no: str | None = None,
allow_sub_min: bool = False,
) -> WithdrawOrder:
"""发起提现:原子扣款 + 建单 reviewing(待人工审核),**不打款**。
@@ -619,8 +620,13 @@ def create_withdraw(
重复发起多笔提现(审核拒绝再退回)。
#2 out_bill_no 客户端幂等键:同号重试返回该单现状(reviewing 等审核),不重复扣款建单。
实名 user_name 在此存下(WithdrawOrder.user_name),供异步审核打款时传给微信(达额需实名)。
allow_sub_min:放行低于"提现最低额"的小额(用于 0.01 元调试提现)。仅由 endpoint 在
`skip_review and not is_prod`(debug 包 + 非生产双闸)时置 True;仍受 schema gt=0 与 max 上限约束。
生产恒为 False → 最低额校验照常,绝不可能提 0.01。
"""
if amount_cents < rewards.get_withdraw_min_cents(db) or amount_cents > rewards.get_withdraw_max_cents(db):
min_c = 0 if allow_sub_min else rewards.get_withdraw_min_cents(db)
if amount_cents < min_c or amount_cents > rewards.get_withdraw_max_cents(db):
raise InvalidWithdrawAmountError
# 提现即要求已绑微信:否则审核通过也打不了款,提前拦更友好
+2
View File
@@ -123,6 +123,7 @@ class ComparisonRecordIn(BaseModel):
# pricebot done.params.trace_url 原样上报,落库供记录页「复制调试链接」(dir 名含落盘
# 时分秒前端拼不出,必须由后端透传)。
trace_url: str | None = Field(None, description="本次比价公网调试链接")
total_ms: int | None = Field(None, description="整场比价墙钟耗时(ms)")
# ===== debug 维度(客户端采集上报;旧客户端不带 → None。仅 admin 比价记录页用)=====
# 必须显式声明,否则 model_dump() 落 raw_payload 时被 pydantic 静默丢弃(同上面 coupon_saved 的坑)。
@@ -172,6 +173,7 @@ class ComparisonRecordOut(BaseModel):
items: list = []
comparison_results: list = []
skipped_dish_names: list = []
total_ms: int | None = None
# 「已下单」(店级):该店名在该用户真实下单(source='compare')里出现过即 True。
# 由 list_records 动态算出挂在 ORM 实例上(非 DB 列),from_attributes 读出;缺省 False。
ordered: bool = False
+20 -1
View File
@@ -1,7 +1,9 @@
"""启动确认窗兜底样本的内部上报模型(pricebot → app-server)。"""
from __future__ import annotations
from pydantic import BaseModel
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class LaunchConfirmSampleIn(BaseModel):
@@ -24,3 +26,20 @@ class LaunchConfirmSampleOut(BaseModel):
"""落库结果。"""
id: int
class LaunchConfirmSampleRow(BaseModel):
"""单条样本(列表读出;沉淀脚本 distill 用,payload 原样带出)。"""
model_config = ConfigDict(from_attributes=True)
id: int
created_at: datetime
trace_id: str | None = None
device_id: str | None = None
host_package: str | None = None
target_app: str | None = None
system_locale: str | None = None
exec_success: bool = False
dialog_title: str | None = None
payload: dict | None = None
Binary file not shown.

After

Width:  |  Height:  |  Size: 847 KiB

+13 -4
View File
@@ -198,7 +198,7 @@
选择「<span class="guide-highlight">在浏览器打开</span>
<span class="guide-final">在浏览器里按提示<span class="guide-target">去应用商店下载</span></span>
</h2>
<div class="guide-dismiss" id="wxGuideDismiss">我知道了 ✕</div>
<!-- <div class="guide-dismiss" id="wxGuideDismiss">我知道了 ✕</div> -->
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
@@ -263,7 +263,7 @@
var wxGuide = document.getElementById("wxGuide");
function showWxGuide() { wxGuide.classList.add("show"); }
function hideWxGuide() { wxGuide.classList.remove("show"); }
document.getElementById("wxGuideDismiss").addEventListener("click", hideWxGuide);
// document.getElementById("wxGuideDismiss").addEventListener("click", hideWxGuide); // 「我知道了 ✕」已注释隐藏
if (isWeChat) showWxGuide(); // 微信里一进页面就提示去浏览器(微信内下载必被拦)
function showToast(text) {
@@ -273,15 +273,24 @@
showToast.timer = setTimeout(function () { toast.classList.remove("show"); }, 1400);
}
// ===== 下载按钮:微信内引导去浏览器;iOS 提示;安卓写邀请码 + 跳应用商店 =====
// ===== 应用商店下载:微信内引导去浏览器;iOS 提示;安卓写邀请码 + 跳应用商店 =====
function handleDownload() {
if (isWeChat) { showWxGuide(); return; }
if (isIOS) { alert("iOS 版即将上线,请前往 App Store 搜索「傻瓜比价」"); return; }
copyInviteCode(); // 先把邀请码写进剪贴板(供 App 首启归因),链路丢了还有 landing-track 指纹兜底
openStore();
}
// ===== 官网下载:微信/iOS 同上引导;安卓直接跳 APK 直链 → 弹系统下载弹窗 =====
// APK_URL 跟随页面 host:本地走 LAN、生产走 app-api.shaguabijia.com(见邀请功能文档约定)。
var APK_URL = location.origin + "/media/shaguabijia.apk";
function handleWebsiteDownload() {
if (isWeChat) { showWxGuide(); return; }
if (isIOS) { alert("iOS 版即将上线,请前往 App Store 搜索「傻瓜比价」"); return; }
copyInviteCode(); // 同样先写邀请码进剪贴板(供 App 首启归因)
window.location.href = APK_URL;
}
document.getElementById("dlbtn").addEventListener("click", handleDownload);
document.getElementById("dlbtn2").addEventListener("click", handleDownload);
document.getElementById("dlbtn2").addEventListener("click", handleWebsiteDownload);
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

@@ -37,7 +37,7 @@ B 安装并首启 App
└─ POST /api/v1/invite/bind { invite_code, channel="clipboard" }
后端 repositories/invite.py bind()
└─ 过四道防线 → 建 invite_relation + 给 A、B 各发金币(同事务原子提交)
└─ 过四道防线 → 建 invite_relation(邀请金币已下线,不写金币流水)
```
手动填码这条:B 在邀请页输码 → `InviteRepository.bindManual()``POST /bind { channel="manual" }` → 同一个 `bind()`
@@ -52,20 +52,20 @@ B 安装并首启 App
|---|---|
| 端点 | `app/api/v1/invite.py`:`GET /api/v1/invite/me`(返回 `invite_code` + `share_url` + 战绩)、`POST /api/v1/invite/bind`(绑定,`channel` = `clipboard` / `manual`)。**均需 Bearer 鉴权**。 |
| share_url 构造 | `invite.py``my_invite`:`settings.INVITE_LANDING_URL + "?ref=" + code``INVITE_LANDING_URL``app/core/config.py`(默认 `https://app-api.shaguabijia.com/media/dl.html`)。 |
| 业务逻辑 | `app/repositories/invite.py`:`ensure_code`(懒生成 6 位邀请码,去混淆字符集,唯一约束碰撞则换码)/ `resolve_inviter`(邀请码→邀请人,大小写不敏感)/ `bind`(下面详述)/ `get_stats`(已邀人数 + 累计金币)。 |
| 业务逻辑 | `app/repositories/invite.py`:`ensure_code`(懒生成 6 位邀请码,去混淆字符集,唯一约束碰撞则换码)/ `resolve_inviter`(邀请码→邀请人,大小写不敏感)/ `bind`(下面详述)/ `get_stats`(已邀人数 + 兼容累计金币字段,当前恒为 0)。 |
| 数据模型 | `app/models/invite.py``InviteRelation`(`inviter_user_id` / `invitee_user_id` / `channel` / `status` / `inviter_coin` / `invitee_coin` / `created_at`)+ `app/models/user.py``User.invite_code` 列。 |
| 迁移 | `alembic/versions/invite_code_and_relation.py`:给 `user``invite_code`(唯一索引)+ 建 `invite_relation` 表。`down_revision = 11a1d08c6f55`。 |
| 收发模型 | `app/schemas/invite.py`:`InviteInfoOut` / `BindInviteIn` / `BindInviteOut`。 |
| 奖励常量 | `app/core/rewards.py`:`INVITE_INVITER_COINS` / `INVITE_INVITEE_COINS`(各 10000 = 1 元)、`INVITE_NEW_USER_WINDOW_HOURS`(72)。 |
| 新人窗口 | `app/core/rewards.py`:`INVITE_NEW_USER_WINDOW_HOURS`(72)。邀请金币已下线,不再配置邀请金币常量。 |
**`bind()` 的四道防线(防重复 / 防刷,看 `repositories/invite.py`):**
1. **被邀请人唯一**:`invitee_user_id` 唯一约束 → 一个 B 只能被绑一次(幂等键,重复返回 `already_bound`,不重复发奖)。
1. **被邀请人唯一**:`invitee_user_id` 唯一约束 → 一个 B 只能被绑一次(幂等键,重复返回 `already_bound`,不重复绑定)。
2. **自邀屏蔽**:`inviter == invitee``self_invite`
3. **新人闸**:`_is_new_user`(B 的 `created_at``INVITE_NEW_USER_WINDOW_HOURS` = 72h 内)才发奖,挡存量老用户互相填码薅羊毛 → 否则 `not_eligible`
3. **新人闸**:`_is_new_user`(B 的 `created_at``INVITE_NEW_USER_WINDOW_HOURS` = 72h 内)才生效,挡存量老用户互相填码刷关系 → 否则 `not_eligible`
4. **手机号唯一**(天然限量):每个 B = 一个真实手机号账号。
发金币复用 `repositories/wallet.py``grant_coins`,与建关系记录在**同一事务**提交,保证"建关系 + 双方加金币"原子
邀请金币已下线:`bind()` 只记录绑定关系,不再写 `coin_transaction`;响应里的金币字段保留兼容旧客户端,当前恒为 0
### 3.2 前端(shaguabijia-app-android)
@@ -120,7 +120,7 @@ B 安装并首启 App
### 4.4 测试硬约束 / 坑(都是机制,不是 bug)
- **B 必须用新手机号**:`invitee_user_id` 唯一,一个 B 只能绑一次;反复测要换号(或手删 `invite_relation` 那行 + 回滚金币)。
- **72h 新人闸**:B 注册后 72 小时内绑才发奖(刚注册肯定满足)。
- **72h 新人闸**:B 注册后 72 小时内绑定才生效(刚注册肯定满足)。
- **A ≠ B**:自邀被屏蔽。
- **B 从点下载到首启 App 之间别复制别的东西**:剪贴板会被覆盖 → 归因丢(剪贴板 deferred deeplink 的固有脆弱性)。
- **笔记本 IP 别变**:debug 包把 `BASE_URL` 的 IP 烧死在编译期,DHCP 一换就连不上 → 给笔记本固定个 LAN IP。
+7771
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -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);
+162
View File
@@ -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);
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# 编 release 正式包并发布到官网下载位 data/media/shaguabijia.apk。
#
# 背景:落地页 dl.html「官网下载」按钮指向 /media/shaguabijia.apk(见 app/main.py download_apk 路由)。
# 该 apk 不入 git(.gitignore 忽略 *.apk),也没有 CI 自动放 —— 以前全靠手动 cp,常忘/放成 debug 包。
# 本脚本把"编 release + 放到位"固化成一条命令。发版时跑一次即可。
#
# 用法: bash scripts/publish_apk.sh
# 产物: <app-server>/data/media/shaguabijia.apk(release 签名、指向生产后端的正式包)
#
# 注意:① 需要 app/jishisongfu-release.jks 存在(release 签名,仅 CTO 持有);缺失会编译失败。
# ② 服务器部署不在本脚本职责内 —— 它只把 apk 放到本仓 data/media/;上线由部署流程把
# data/media/ 同步到生产(或 nginx serve 同目录)。
set -euo pipefail
# 路径全部相对脚本位置算,跟 CWD 无关。
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SERVER_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
ANDROID_DIR="$(cd "$SERVER_DIR/../shaguabijia-app-android" && pwd)"
DEST="$SERVER_DIR/data/media/shaguabijia.apk"
APK_OUT="$ANDROID_DIR/app/build/outputs/apk/release/app-release.apk"
# Android Studio 自带 JBR(本机构建环境约定)。已设 JAVA_HOME 则尊重现有值。
export JAVA_HOME="${JAVA_HOME:-D:/android-studio/jbr}"
echo "[publish_apk] Android 工程: $ANDROID_DIR"
if [ ! -f "$ANDROID_DIR/app/jishisongfu-release.jks" ]; then
echo "[publish_apk] ✗ 缺 app/jishisongfu-release.jks(release 签名),无法出正式包。" >&2
exit 1
fi
echo "[publish_apk] 编 release 包(assembleRelease,R8+release 签名,BASE_URL=生产)…"
( cd "$ANDROID_DIR" && ./gradlew :app:assembleRelease )
if [ ! -f "$APK_OUT" ]; then
echo "[publish_apk] ✗ 没找到产物 $APK_OUT" >&2
exit 1
fi
mkdir -p "$(dirname "$DEST")"
cp "$APK_OUT" "$DEST"
SIZE_MB=$(( $(wc -c < "$DEST") / 1024 / 1024 ))
echo "[publish_apk] ✓ 已发布 → $DEST (${SIZE_MB}MB)"
echo "[publish_apk] 官网下载链接(生产):https://app-api.shaguabijia.com/media/shaguabijia.apk"
echo "[publish_apk] 部署:把 data/media/ 同步到生产服务器(本脚本不负责上线)。"
+2 -1
View File
@@ -305,11 +305,12 @@ def test_feed_reward_grants_by_10_second_units(client) -> None:
"duration_seconds": 30,
"adn": "pangle",
"slot_id": "slot_feed",
"display_coin": 4,
}
r = client.post("/api/v1/ad/feed-reward", json=payload, headers=_auth(token))
assert r.status_code == 200, r.text
body = r.json()
expected = sum(calculate_ad_reward_coin("200", i) for i in range(1, 4))
expected = 4
assert body["granted"] is True
assert body["status"] == "granted"
assert body["unit_count"] == 3
+2 -1
View File
@@ -64,7 +64,8 @@ def test_dashboard_overview(admin_client: TestClient, admin_token: str) -> None:
assert data["users"]["total"] >= 1
assert data["coins"]["granted_total"] >= 5000
assert "success_rate" in data["comparison"]
assert data["cps"]["available"] is False
assert data["cps"]["available"] is True
assert "meituan_order_count" in data["cps"]
def test_user_list_and_detail(admin_client: TestClient, admin_token: str) -> None:
+3
View File
@@ -44,6 +44,7 @@ def _food_payload(trace_id: str) -> dict:
"skipped_dish_names": ["黑牛肉卷"],
"total_dish_count": 3,
"information": "在美团找到同店,到手价 ¥123.50",
"total_ms": 12345,
}
@@ -71,6 +72,8 @@ def test_report_and_derive(client) -> None:
assert d["information"] == "在美团找到同店,到手价 ¥123.50"
assert d["store_name"] == "海底捞(朝阳店)"
assert d["total_dish_count"] == 3
assert d["total_ms"] == 12345
assert d["raw_payload"]["total_ms"] == 12345
assert d["skipped_dish_count"] == 1
assert d["skipped_dish_names"] == ["黑牛肉卷"]
assert len(d["comparison_results"]) == 3
+2 -1
View File
@@ -52,7 +52,8 @@ def call_raw(path: str, body_obj: dict) -> dict:
}
url = f"{settings.MT_CPS_HOST}{path}"
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)
try:
j = resp.json()