feat: 福利钱包+签到+省钱战绩+激励广告发奖+微信提现 后端
- 福利钱包: 金币/现金余额、流水、兑换 (api/v1/wallet.py, crud/wallet.py, models/wallet.py, schemas/welfare.py) - 每日签到: 连续天数 + 档位奖励 (api/v1/signin.py, crud/signin.py, models/signin.py) - 任务系统: "打开消息提醒"等任务领奖 (api/v1/tasks.py, crud/task.py, models/task.py) - 省钱战绩: 省钱汇总/战绩/店铺菜品 (api/v1/savings.py, crud/savings.py, models/savings.py) - 激励广告发奖: 穿山甲服务端回调 + 发奖规则 + 限流 (api/v1/ad.py, core/pangle.py, core/rewards.py, core/ratelimit.py, crud/ad_reward.py, schemas/ad.py, docs/ad_reward_golive_checklist.md) - 微信提现: 商家转账到零钱 V3 (core/wxpay.py); user 表加微信 openid/nickname/avatar - DB 迁移: 8 个 alembic (welfare 表/cash_transaction/openid 唯一约束/savings 店铺菜品/savings_record/ad_reward/withdraw_order+openid/user 微信字段) - 运维脚本: reconcile_withdraws(对账) + reset_signin/reset_welfare + sim_pangle_callback(模拟穿山甲回调) - 测试: test_welfare / test_withdraw / test_ad_reward - 配置: .env.example + config.py 新增福利/广告/微信支付项; main.py 挂 5 个新路由 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
"""提现对账(#4 孤儿单兜底)。
|
||||
|
||||
扫描超过 N 分钟仍 pending 的提现单,逐单查微信归一化:
|
||||
- 微信 SUCCESS → 置 success
|
||||
- 微信失败/取消/关闭/无此单 → 退回现金 + 置 failed
|
||||
- WAIT_USER_CONFIRM(久未确认)→ 撤销 + 退款
|
||||
防止"扣了款但转账没发起/没确认"的单永久锁住用户余额。
|
||||
|
||||
用法(建议 cron / systemd timer 每 5~10 分钟跑一次):
|
||||
python -m scripts.reconcile_withdraws # 默认处理 >15 分钟的 pending
|
||||
python -m scripts.reconcile_withdraws 30 # 处理 >30 分钟的 pending
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
# Windows 控制台按 UTF-8 输出中文/¥
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
from app.crud import wallet as crud_wallet
|
||||
from app.db.session import SessionLocal
|
||||
|
||||
|
||||
def main() -> None:
|
||||
minutes = int(sys.argv[1]) if len(sys.argv) > 1 else 15
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = crud_wallet.reconcile_pending_withdraws(db, older_than_minutes=minutes)
|
||||
print(f"对账完成: 检查 {result['checked']} 笔 pending(>{minutes}分钟), 归结 {result['resolved']} 笔")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,121 @@
|
||||
"""清空指定用户的签到记录,方便反复测试"未签到→自动弹窗→签到领金币"。
|
||||
|
||||
默认**只删 signin_record**(签到历史),不动金币/现金——这样能保留你已注入/兑换的余额,
|
||||
只把"今天是否已签到 / 连续天数"重置掉。删后当天即变成"未签到",再进福利页会重新自动弹签到弹窗。
|
||||
|
||||
⚠️ 注意:只删签到记录的话,之前签到已发的金币仍留在余额和 coin_transaction 里(biz_type='signin')。
|
||||
如果想把签到发的金币也一并退掉(余额减回、删 signin 流水),加 --with-coins。
|
||||
|
||||
用法(在 shaguabijia-app-server 目录下,用本项目的 python 跑):
|
||||
python scripts/reset_signin.py # 只清 user_id=2 的签到记录
|
||||
python scripts/reset_signin.py 5 # 清 user_id=5
|
||||
python scripts/reset_signin.py --all # 清所有用户的签到记录
|
||||
python scripts/reset_signin.py --with-coins # 连签到发的金币一起退回(余额减、删 signin 流水)
|
||||
python scripts/reset_signin.py --dry-run # 只看现状,不改
|
||||
|
||||
DB 默认取 <项目根>/data/app.db,可用 --db 指定。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Windows 控制台默认 GBK,强制 UTF-8 否则中文输出乱码
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
# 本脚本在 scripts/ 下,项目根是上一级
|
||||
DEFAULT_DB = Path(__file__).resolve().parent.parent / "data" / "app.db"
|
||||
|
||||
|
||||
def snapshot(cur: sqlite3.Cursor, where: str, params: tuple) -> dict:
|
||||
"""返回签到记录数 + 签到金币流水数/总额 + 账户金币余额,用于打印 before/after。"""
|
||||
signin_rows = cur.execute(
|
||||
f"select count(*) from signin_record where {where}", params
|
||||
).fetchone()[0]
|
||||
signin_coin_rows, signin_coin_sum = cur.execute(
|
||||
f"select count(*), coalesce(sum(amount), 0) from coin_transaction "
|
||||
f"where biz_type='signin' and {where}",
|
||||
params,
|
||||
).fetchone()
|
||||
accounts = cur.execute(
|
||||
f"select user_id, coin_balance, total_coin_earned from coin_account where {where}",
|
||||
params,
|
||||
).fetchall()
|
||||
return {
|
||||
"signin_rows": signin_rows,
|
||||
"signin_coin_rows": signin_coin_rows,
|
||||
"signin_coin_sum": signin_coin_sum,
|
||||
"accounts": accounts,
|
||||
}
|
||||
|
||||
|
||||
def print_snapshot(label: str, snap: dict) -> None:
|
||||
print(f"--- {label} ---")
|
||||
print(f" signin_record: {snap['signin_rows']}")
|
||||
print(f" coin_transaction(signin): {snap['signin_coin_rows']} 条, 共 {snap['signin_coin_sum']} 金币")
|
||||
if snap["accounts"]:
|
||||
for uid, coin, earned in snap["accounts"]:
|
||||
print(f" coin_account user={uid}: coin={coin} earned={earned}")
|
||||
else:
|
||||
print(" coin_account: (无)")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="清空用户签到记录")
|
||||
parser.add_argument("user_id", nargs="?", type=int, default=2,
|
||||
help="要清的 user_id(默认 2)")
|
||||
parser.add_argument("--all", action="store_true", help="清所有用户")
|
||||
parser.add_argument("--with-coins", action="store_true",
|
||||
help="同时退回签到发的金币(余额减、删 signin 流水)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只看现状,不修改")
|
||||
parser.add_argument("--db", default=str(DEFAULT_DB), help="SQLite 路径")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.all:
|
||||
where, params, scope = "1=1", (), "ALL users"
|
||||
else:
|
||||
where, params, scope = "user_id=?", (args.user_id,), f"user_id={args.user_id}"
|
||||
|
||||
db_path = Path(args.db)
|
||||
if not db_path.exists():
|
||||
raise SystemExit(f"DB 不存在: {db_path}")
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cur = conn.cursor()
|
||||
print(f"DB: {db_path} scope: {scope} with_coins: {args.with_coins}")
|
||||
print_snapshot("before", snapshot(cur, where, params))
|
||||
|
||||
if args.dry_run:
|
||||
print("(dry-run,未修改)")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
if args.with_coins:
|
||||
# 先按用户把签到金币总额退回(余额、累计收益都减),再删 signin 流水
|
||||
rows = cur.execute(
|
||||
f"select user_id, coalesce(sum(amount), 0) from coin_transaction "
|
||||
f"where biz_type='signin' and {where} group by user_id",
|
||||
params,
|
||||
).fetchall()
|
||||
for uid, total in rows:
|
||||
cur.execute(
|
||||
"update coin_account set coin_balance = coin_balance - ?, "
|
||||
"total_coin_earned = max(0, total_coin_earned - ?) where user_id = ?",
|
||||
(total, total, uid),
|
||||
)
|
||||
cur.execute(f"delete from coin_transaction where biz_type='signin' and {where}", params)
|
||||
|
||||
cur.execute(f"delete from signin_record where {where}", params)
|
||||
conn.commit()
|
||||
|
||||
print_snapshot("after", snapshot(cur, where, params))
|
||||
conn.close()
|
||||
print("签到记录已清空。提醒:App 内存里的状态不会自动同步,重启 App 或进收益明细页刷新;"
|
||||
"重进福利页(当天未签到)会重新自动弹签到弹窗。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,101 @@
|
||||
"""一键回滚某个用户的福利数据(金币/现金/签到/一次性任务/看广告发奖),方便反复测试。
|
||||
|
||||
清掉:coin_transaction / cash_transaction / signin_record / user_task / ad_reward_record,
|
||||
并把 coin_account 的金币余额、累计收益、现金余额全部归零。
|
||||
**不动** savings_record(那是 profile 省钱卡的 demo 演示数据,与领奖测试无关)。
|
||||
|
||||
用法(在 shaguabijia-app-server 目录下,用本项目的 python 跑):
|
||||
python scripts/reset_welfare.py # 默认重置 user_id=2(手机登录用户)
|
||||
python scripts/reset_welfare.py 5 # 重置 user_id=5
|
||||
python scripts/reset_welfare.py --all # 重置所有用户
|
||||
python scripts/reset_welfare.py --dry-run # 只看现状,不改
|
||||
|
||||
DB 默认取 <项目根>/data/app.db,可用 --db 指定。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Windows 控制台默认 GBK,强制 UTF-8 否则中文输出乱码
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
# 本脚本在 scripts/ 下,项目根是上一级
|
||||
DEFAULT_DB = Path(__file__).resolve().parent.parent / "data" / "app.db"
|
||||
|
||||
# 受影响的表:每个用户的钱包/行为数据
|
||||
WALLET_TABLES = ("coin_transaction", "cash_transaction", "signin_record", "user_task", "ad_reward_record")
|
||||
|
||||
|
||||
def snapshot(cur: sqlite3.Cursor, where: str, params: tuple) -> dict:
|
||||
"""返回当前各表行数 + 账户余额,用于打印 before/after。"""
|
||||
counts = {
|
||||
t: cur.execute(f"select count(*) from {t} where {where}", params).fetchone()[0]
|
||||
for t in WALLET_TABLES
|
||||
}
|
||||
accounts = cur.execute(
|
||||
f"select user_id, coin_balance, cash_balance_cents, total_coin_earned "
|
||||
f"from coin_account where {where}",
|
||||
params,
|
||||
).fetchall()
|
||||
return {"counts": counts, "accounts": accounts}
|
||||
|
||||
|
||||
def print_snapshot(label: str, snap: dict) -> None:
|
||||
print(f"--- {label} ---")
|
||||
for t, n in snap["counts"].items():
|
||||
print(f" {t}: {n}")
|
||||
if snap["accounts"]:
|
||||
for uid, coin, cash, earned in snap["accounts"]:
|
||||
print(f" coin_account user={uid}: coin={coin} cash_cents={cash} earned={earned}")
|
||||
else:
|
||||
print(" coin_account: (无)")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="回滚用户福利数据")
|
||||
parser.add_argument("user_id", nargs="?", type=int, default=2,
|
||||
help="要重置的 user_id(默认 2)")
|
||||
parser.add_argument("--all", action="store_true", help="重置所有用户")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只看现状,不修改")
|
||||
parser.add_argument("--db", default=str(DEFAULT_DB), help="SQLite 路径")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.all:
|
||||
where, params, scope = "1=1", (), "ALL users"
|
||||
else:
|
||||
where, params, scope = "user_id=?", (args.user_id,), f"user_id={args.user_id}"
|
||||
|
||||
db_path = Path(args.db)
|
||||
if not db_path.exists():
|
||||
raise SystemExit(f"DB 不存在: {db_path}")
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cur = conn.cursor()
|
||||
print(f"DB: {db_path} scope: {scope}")
|
||||
print_snapshot("before", snapshot(cur, where, params))
|
||||
|
||||
if args.dry_run:
|
||||
print("(dry-run,未修改)")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
for t in WALLET_TABLES:
|
||||
cur.execute(f"delete from {t} where {where}", params)
|
||||
cur.execute(
|
||||
f"update coin_account set coin_balance=0, total_coin_earned=0, "
|
||||
f"cash_balance_cents=0 where {where}",
|
||||
params,
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
print_snapshot("after", snapshot(cur, where, params))
|
||||
conn.close()
|
||||
print("回滚完成。提醒:App 内存里的共享余额态不会自动同步,重启 App 或进收益明细页刷新。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,89 @@
|
||||
"""模拟穿山甲激励视频 S2S 发奖回调,本地验证「验签 → 幂等发币 → 余额到账」闭环。
|
||||
|
||||
真穿山甲回调需要后端公网可达 + 穿山甲后台配置"奖励回调 URL",本地内网拿不到。本脚本用
|
||||
FastAPI TestClient 在进程内打**真实** endpoint(`/api/v1/ad/pangle-callback`)、写**真实** DB
|
||||
(./data/app.db),绕开公网依赖,证明后端这半条链路 OK——发完进 App 收益明细/重进福利页即可看到余额。
|
||||
|
||||
用法(在 shaguabijia-app-server 目录下,用本项目 python 跑):
|
||||
python scripts/sim_pangle_callback.py # 给最近注册用户发一次(随机 trans_id)
|
||||
python scripts/sim_pangle_callback.py 2 # 指定 user_id=2
|
||||
python scripts/sim_pangle_callback.py 2 --trans t1 # 指定交易号;同号再跑应 Δ0(验幂等)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
# 必须在 import app.* 之前开 PANGLE 开关(settings 在 import 时构造并缓存);
|
||||
# 这里 setdefault 不覆盖 .env / 真实环境已有的值。
|
||||
os.environ.setdefault("PANGLE_CALLBACK_ENABLED", "true")
|
||||
os.environ.setdefault("PANGLE_REWARD_SECRET", "dev-local-pangle-secret")
|
||||
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
from sqlalchemy import select # noqa: E402
|
||||
|
||||
from app.core import pangle # noqa: E402
|
||||
from app.core.config import settings # noqa: E402
|
||||
from app.core.rewards import AD_REWARD_COIN # noqa: E402
|
||||
from app.crud import wallet as crud_wallet # noqa: E402
|
||||
from app.db.session import SessionLocal # noqa: E402
|
||||
from app.main import app # noqa: E402
|
||||
from app.models.user import User # noqa: E402
|
||||
|
||||
|
||||
def _latest_user_id() -> int | None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return db.execute(select(User.id).order_by(User.id.desc())).scalars().first()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _coin_balance(user_id: int) -> int:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return crud_wallet.get_or_create_account(db, user_id).coin_balance
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
p = argparse.ArgumentParser(description="模拟穿山甲发奖回调")
|
||||
p.add_argument("user_id", nargs="?", type=int, default=None, help="目标 user_id(默认最近注册)")
|
||||
p.add_argument("--trans", default=None, help="交易号(默认随机);同号重跑验幂等")
|
||||
args = p.parse_args()
|
||||
|
||||
if not settings.pangle_callback_configured:
|
||||
raise SystemExit("PANGLE 未配置(PANGLE_CALLBACK_ENABLED / PANGLE_REWARD_SECRET)")
|
||||
|
||||
uid = args.user_id or _latest_user_id()
|
||||
if uid is None:
|
||||
raise SystemExit("DB 里没有用户,先在 App 登录一次再跑")
|
||||
|
||||
trans_id = args.trans or f"sim_{uuid.uuid4().hex[:12]}"
|
||||
before = _coin_balance(uid)
|
||||
|
||||
params = {
|
||||
"user_id": str(uid),
|
||||
"trans_id": trans_id,
|
||||
"reward_name": "金币",
|
||||
"reward_amount": "1",
|
||||
}
|
||||
params["sign"] = pangle.build_sign(params, settings.PANGLE_REWARD_SECRET)
|
||||
|
||||
resp = TestClient(app).get("/api/v1/ad/pangle-callback", params=params)
|
||||
after = _coin_balance(uid)
|
||||
|
||||
print(f"user_id={uid} trans_id={trans_id}")
|
||||
print(f"HTTP {resp.status_code} {resp.json()}")
|
||||
print(f"金币: {before} -> {after} (Δ{after - before}, 单次应发 {AD_REWARD_COIN})")
|
||||
print("同 trans_id 再跑应 Δ0(幂等);进 App 收益明细/重进福利页刷新即可看到余额。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user