diff --git a/.gitignore b/.gitignore index d473d6b..4c87d95 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/app/api/v1/wallet.py b/app/api/v1/wallet.py index 8641cf2..29a0407 100644 --- a/app/api/v1/wallet.py +++ b/app/api/v1/wallet.py @@ -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( diff --git a/app/main.py b/app/main.py index 7b5e376..6aded37 100644 --- a/app/main.py +++ b/app/main.py @@ -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,14 +41,14 @@ from app.api.v1.user import router as user_router from app.api.v1.wallet import router as wallet_router from app.api.v1.wxpay import router as wxpay_router from app.core.config import settings -from app.core.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 ( @@ -137,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)), diff --git a/app/repositories/invite.py b/app/repositories/invite.py index 7fd7dbc..8dbd290 100644 --- a/app/repositories/invite.py +++ b/app/repositories/invite.py @@ -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)) diff --git a/app/repositories/wallet.py b/app/repositories/wallet.py index 611bbc9..1edd034 100644 --- a/app/repositories/wallet.py +++ b/app/repositories/wallet.py @@ -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 # 提现即要求已绑微信:否则审核通过也打不了款,提前拦更友好 diff --git a/data/media/coupon-page-bg.png b/data/media/coupon-page-bg.png new file mode 100644 index 0000000..19113f6 Binary files /dev/null and b/data/media/coupon-page-bg.png differ diff --git a/data/media/dl.html b/data/media/dl.html index f580014..9c84b51 100644 --- a/data/media/dl.html +++ b/data/media/dl.html @@ -198,7 +198,7 @@ 选择「在浏览器打开」 在浏览器里按提示去应用商店下载 -