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 @@ 选择「在浏览器打开在浏览器里按提示去应用商店下载 -
我知道了 ✕
+
@@ -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); diff --git a/data/media/sb-brand.png b/data/media/sb-brand.png new file mode 100644 index 0000000..c8f1c13 Binary files /dev/null and b/data/media/sb-brand.png differ diff --git a/scripts/publish_apk.sh b/scripts/publish_apk.sh new file mode 100644 index 0000000..3cc5c96 --- /dev/null +++ b/scripts/publish_apk.sh @@ -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 +# 产物: /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/ 同步到生产服务器(本脚本不负责上线)。"