From 57ddcd356b7b19d68699fca9f80359eb3088384b Mon Sep 17 00:00:00 2001 From: ouzhou Date: Thu, 11 Jun 2026 23:36:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(wallet):=200=20=E7=82=B9=E5=AE=9A=E6=97=B6?= =?UTF-8?q?=E6=8A=8A=E7=94=A8=E6=88=B7=E9=87=91=E5=B8=81=E3=80=8C=E5=88=B0?= =?UTF-8?q?=E5=88=86=E5=85=A8=E9=A2=9D=E3=80=8D=E8=87=AA=E5=8A=A8=E5=85=91?= =?UTF-8?q?=E6=88=90=E7=8E=B0=E9=87=91=20(#45)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 客户端已删手动兑入口、改「0 点自动兑现金」,服务端补这条批处理: - wallet.exchange_coins_to_cash 加 remark / enforce_min 参数:enforce_min=False(自动兑)只要够 1 分(COIN_PER_CENT)即可,不受手动兑下限约束;remark 可定制。 - 新增 wallet.daily_auto_exchange(db):扫 coin_balance>=100 的用户,到分全额(floor(余额/100)*100) 兑现金,零头留下次;幂等键=当天是否已有 exchange_in 流水(_has_exchange_in_on);逐用户独立事务, 单用户异常 rollback 不中断。 - scripts/daily_auto_exchange.py(--once / --loop):带 .env AUTO_EXCHANGE_ENABLED 开关(false→no-op) + 文件锁防重入;deploy/ 配 systemd service+timer(0 点触发)+ 说明。 - config.AUTO_EXCHANGE_ENABLED(默认 true)+ .env.example。 Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: OuYingJun1024 <1034284404@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/45 Co-authored-by: ouzhou Co-committed-by: ouzhou --- .env.example | 2 + app/core/config.py | 4 ++ app/repositories/wallet.py | 86 +++++++++++++++++++++++-- deploy/daily-exchange.md | 59 +++++++++++++++++ deploy/daily-exchange.service | 33 ++++++++++ deploy/daily-exchange.timer | 14 ++++ scripts/daily_auto_exchange.py | 113 +++++++++++++++++++++++++++++++++ 7 files changed, 304 insertions(+), 7 deletions(-) create mode 100644 deploy/daily-exchange.md create mode 100644 deploy/daily-exchange.service create mode 100644 deploy/daily-exchange.timer create mode 100644 scripts/daily_auto_exchange.py diff --git a/.env.example b/.env.example index e4278ed..7178490 100644 --- a/.env.example +++ b/.env.example @@ -83,6 +83,8 @@ WXPAY_AUTH_NOTIFY_URL= WITHDRAW_AUTO_RECONCILE_ENABLED=false WITHDRAW_AUTO_RECONCILE_INTERVAL_SEC=300 WITHDRAW_AUTO_RECONCILE_OLDER_THAN_MINUTES=15 +# 0 点自动兑金币开关(deploy/daily-exchange.timer 触发的脚本读它;false=脚本 no-op)。默认 true。 +AUTO_EXCHANGE_ENABLED=true # ===== 穿山甲激励视频(服务端发奖回调)===== # 看完激励视频后穿山甲服务器 S2S 回调本服务发金币(客户端不参与发奖)。 diff --git a/app/core/config.py b/app/core/config.py index 2742f4d..a64c3a8 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -105,6 +105,10 @@ class Settings(BaseSettings): WITHDRAW_AUTO_RECONCILE_ENABLED: bool = False WITHDRAW_AUTO_RECONCILE_INTERVAL_SEC: int = 300 WITHDRAW_AUTO_RECONCILE_OLDER_THAN_MINUTES: int = 15 + # 0 点自动兑金币(由 deploy/daily-exchange.timer 触发 scripts.daily_auto_exchange)。 + # 客户端已删手动兑入口、改为「0 点自动兑现金」,故默认开;运营要临时停可在 .env 置 false, + # 无需动 systemd timer——脚本读此开关,false 时直接 no-op 退出。 + AUTO_EXCHANGE_ENABLED: bool = True # 免确认收款授权(用户授权免确认模式)的授权结果回调地址,必须公网可访问 HTTPS、不带参数。 # 发起授权 / 首单顺带授权时作为 authorization_notify_url 传给微信。一期不处理回调内容 # (授权状态靠 query 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。 diff --git a/app/repositories/wallet.py b/app/repositories/wallet.py index 606e422..7be86b4 100644 --- a/app/repositories/wallet.py +++ b/app/repositories/wallet.py @@ -183,14 +183,22 @@ def list_coin_transactions( def exchange_coins_to_cash( - db: Session, user_id: int, coin_amount: int + db: Session, + user_id: int, + coin_amount: int, + *, + remark: str | None = None, + enforce_min: bool = True, ) -> tuple[CoinAccount, int]: """金币兑现金。返回 (account, 本次兑入的分)。 - 校验:金额 >= MIN_EXCHANGE_COIN 且为整分倍数(InvalidExchangeAmountError), - 余额充足(InsufficientCoinError)。扣金币 + 加现金,两条流水同事务 commit。 + 校验:金额为整分倍数(InvalidExchangeAmountError)、余额充足(InsufficientCoinError); + `enforce_min=True`(手动兑默认)时还要 >= 后台配置的兑换下限,`enforce_min=False` + (0 点自动兑「到分全额」用)只要够 1 分(COIN_PER_CENT)即可,不受手动下限约束。 + `remark` 不传则用默认「N金币兑M分」。扣金币 + 加现金,两条流水同事务 commit。 """ - if coin_amount < rewards.get_min_exchange_coin(db) or coin_amount % COIN_PER_CENT != 0: + floor_min = rewards.get_min_exchange_coin(db) if enforce_min else COIN_PER_CENT + if coin_amount < floor_min or coin_amount % COIN_PER_CENT != 0: raise InvalidExchangeAmountError acc = get_or_create_account(db, user_id, commit=False) @@ -198,10 +206,10 @@ def exchange_coins_to_cash( raise InsufficientCoinError cents = coins_to_cents(coin_amount) - remark = f"{coin_amount}金币兑{cents}分" + tx_remark = remark or f"{coin_amount}金币兑{cents}分" # 1. 扣金币(写 coin_transaction) - grant_coins(db, user_id, -coin_amount, biz_type="exchange_out", remark=remark) + grant_coins(db, user_id, -coin_amount, biz_type="exchange_out", remark=tx_remark) # 2. 加现金(写 cash_transaction) acc.cash_balance_cents += cents db.add( @@ -210,7 +218,7 @@ def exchange_coins_to_cash( amount_cents=cents, balance_after_cents=acc.cash_balance_cents, biz_type="exchange_in", - remark=remark, + remark=tx_remark, created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None), ) ) @@ -219,6 +227,70 @@ def exchange_coins_to_cash( return acc, cents +def _has_exchange_in_on(db: Session, user_id: int, day) -> bool: + """该用户在指定日期(北京日)是否已有 exchange_in 现金流水。 + + 自动兑复用 exchange_in 类型,故用「当天是否已有 exchange_in」做幂等键:timer 重触发 / + 手动重跑当天不会重复兑。手动兑入口已下线,不会与之误判混淆(若日后恢复手动兑需改用独立类型)。 + created_at 落库为北京 naive 时间(见 exchange_coins_to_cash),按 [当天 0 点, 次日 0 点) 比较。 + """ + day_start = datetime.combine(day, datetime.min.time()) + next_day = day_start + timedelta(days=1) + return ( + db.execute( + select(CashTransaction.id) + .where( + CashTransaction.user_id == user_id, + CashTransaction.biz_type == "exchange_in", + CashTransaction.created_at >= day_start, + CashTransaction.created_at < next_day, + ) + .limit(1) + ).first() + is not None + ) + + +def daily_auto_exchange(db: Session) -> dict: + """0 点定时任务:把每个用户金币「到分全额」兑成现金(复用 exchange_in 流水类型)。 + + - **到分全额**:coin_amount = floor(余额 / 100) * 100;余额不足 1 分(<100)的零头留到下次。 + - **幂等**:当天已有 exchange_in 的用户跳过(见 [_has_exchange_in_on])。 + - **逐用户独立事务**:单个用户异常 rollback 不影响其他人;exchange_coins_to_cash 内部按用户 commit。 + 返回统计 dict(scanned/converted/skipped_done/skipped_dust/failed/total_cents)。 + """ + today = rewards.cn_today() + stats = { + "scanned": 0, "converted": 0, "skipped_done": 0, + "skipped_dust": 0, "failed": 0, "total_cents": 0, + } + user_ids = db.execute( + select(CoinAccount.user_id).where(CoinAccount.coin_balance >= COIN_PER_CENT) + ).scalars().all() + + for user_id in user_ids: + stats["scanned"] += 1 + try: + if _has_exchange_in_on(db, user_id, today): + stats["skipped_done"] += 1 + continue + acc = get_or_create_account(db, user_id, commit=False) + coin_amount = (acc.coin_balance // COIN_PER_CENT) * COIN_PER_CENT + if coin_amount < COIN_PER_CENT: + stats["skipped_dust"] += 1 + continue + _, cents = exchange_coins_to_cash( + db, user_id, coin_amount, remark="0 点自动兑现金", enforce_min=False + ) + stats["converted"] += 1 + stats["total_cents"] += cents + except Exception: # noqa: BLE001 - 批处理不因单用户异常中断 + db.rollback() + stats["failed"] += 1 + + return stats + + def list_cash_transactions( db: Session, user_id: int, diff --git a/deploy/daily-exchange.md b/deploy/daily-exchange.md new file mode 100644 index 0000000..70c4b71 --- /dev/null +++ b/deploy/daily-exchange.md @@ -0,0 +1,59 @@ +# 0 点自动兑金币 定时任务 — 运维手册 + +> 对象:维护「每天 0 点把用户金币自动兑成现金」这套定时任务的同事。 +> 🔒 服务器登录信息见**私密交接清单**,不入库。 + +## 它是什么 +客户端已删手动「兑金币」入口、改为「0 点自动兑现金(可能存在延迟)」,故服务端每天 0 点跑一轮: +把每个用户的金币按汇率 **100 金币 = 1 分** 兑成现金。 + +- **到分全额**:`coin_amount = floor(余额 / 100) × 100`,不足 1 分(<100 金币)的零头留到下次。 +- **复用 `exchange_in` 流水类型**(手动兑入口已下线,不会混淆),用户在「现金明细」看到「金币兑换现金」。 +- **幂等**:同一用户当天已有 `exchange_in` 流水则跳过 → timer 重触发 / 手动重跑当天不会重复兑。 +- 逻辑见 `app/repositories/wallet.py::daily_auto_exchange`,入口 `scripts/daily_auto_exchange.py`。 + +## 文件 +| 项 | 路径 | +|---|---| +| 脚本入口 | `scripts/daily_auto_exchange.py` | +| 核心逻辑 | `app/repositories/wallet.py::daily_auto_exchange` | +| 开关 | `.env` 的 `AUTO_EXCHANGE_ENABLED`(默认 true;false=脚本 no-op) | +| systemd 单元 | `deploy/daily-exchange.{service,timer}` | +| 运行锁 | `/tmp/daily_auto_exchange.lock`(可用环境变量 `DAILY_EXCHANGE_LOCK` 覆盖) | + +## 部署(Linux 服务器) +```bash +sudo cp deploy/daily-exchange.{service,timer} /etc/systemd/system/ +sudo systemctl daemon-reload && sudo systemctl enable --now daily-exchange.timer +systemctl list-timers daily-exchange.timer # 确认下次触发时间 +``` + +## 本机 Windows 开发(无 systemd) +直接手动跑: +``` +.venv\Scripts\python -m scripts.daily_auto_exchange --once +# 本地循环调试(每小时一轮,幂等空跑无害): +.venv\Scripts\python -m scripts.daily_auto_exchange --loop --interval 3600 +``` + +## 怎么看健康 / 手动跑一次 +```bash +sudo systemctl start daily-exchange.service # 立即手动跑一轮(不等 0 点) +journalctl -u daily-exchange -n 30 --no-pager # 看日志:扫描/兑换/跳过/失败/累计兑入分 +``` +日志一行形如:`自动兑完成 用时1.2s 扫描120 兑换88 已兑过跳过30 零头跳过2 失败0 累计兑入15600分`。 + +## 怎么关 / 临时停 +- **临时停(推荐)**:`.env` 置 `AUTO_EXCHANGE_ENABLED=false` → 脚本 no-op,无需动 timer。 +- **停 timer**:`sudo systemctl disable --now daily-exchange.timer`。 + +## 脚本参数 +- `--once`:跑一轮(timer 用这个,默认) +- `--loop --interval N`:常驻循环,每 N 秒一轮(默认 3600,调试用) +- 锁位置可用环境变量 `DAILY_EXCHANGE_LOCK=/path/to.lock` 覆盖 + +## 注意事项 +- **触发时间**:`OnCalendar=*-*-* 00:00:00`。客户端文案已注明「可能存在延迟」,要错开整点扎堆可改 `00:05:00`。 +- **catch-up**:`Persistent=true`,服务器宕机错过当天会在重启后补跑一轮。 +- **DB 无关**:sqlite / postgres 均可(不像美团 ETL 需要 PG)。 +- **改脚本 / 改部署**:走 git + PR,由有 root 的人部署。 diff --git a/deploy/daily-exchange.service b/deploy/daily-exchange.service new file mode 100644 index 0000000..aef841a --- /dev/null +++ b/deploy/daily-exchange.service @@ -0,0 +1,33 @@ +# 0 点自动兑金币 —— 单轮把每个用户金币「到分全额」兑成现金,由 daily-exchange.timer 每天 0 点触发。 +# +# 仅用于 Linux 服务器;本机 Windows 开发无 systemd,直接手动跑脚本即可: +# .venv\Scripts\python -m scripts.daily_auto_exchange --once +# +# 部署(服务器): +# sudo cp deploy/daily-exchange.{service,timer} /etc/systemd/system/ +# sudo systemctl daemon-reload && sudo systemctl enable --now daily-exchange.timer +# # 手动跑一次验证: sudo systemctl start daily-exchange.service && journalctl -u daily-exchange -n 30 +# +# 前置:.env 的 AUTO_EXCHANGE_ENABLED=true(默认);置 false 则脚本 no-op,免动 timer。 +[Unit] +Description=Daily auto-exchange coins to cash (one-shot, driven by timer) +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +User=root +WorkingDirectory=/opt/shaguabijia-app-server +Environment="PATH=/opt/shaguabijia-app-server/.venv/bin:/usr/bin:/bin" +EnvironmentFile=/opt/shaguabijia-app-server/.env +ExecStart=/opt/shaguabijia-app-server/.venv/bin/python -m scripts.daily_auto_exchange --once +SyslogIdentifier=daily-exchange +# 脚本自带 30min 文件锁;给 20min 硬超时,防卡死轮次长期占锁。 +TimeoutStartSec=1200 + +# 与主服务 shaguabijia-app-server.service 同款加固。 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ReadWritePaths=/opt/shaguabijia-app-server +ProtectHome=true diff --git a/deploy/daily-exchange.timer b/deploy/daily-exchange.timer new file mode 100644 index 0000000..d00520b --- /dev/null +++ b/deploy/daily-exchange.timer @@ -0,0 +1,14 @@ +# 每天 0 点触发一次「自动兑金币」(Linux 服务器用)。 +# 见 daily-exchange.service 顶部注释的部署步骤。 +[Unit] +Description=Run daily auto-exchange coins->cash at midnight + +[Timer] +# 每天 0 点跑。客户端文案已注明「可能存在延迟」,可按需改 00:05 错开整点扎堆。 +OnCalendar=*-*-* 00:00:00 +# 服务器宕机/重启后,补跑错过的那一轮(而不是干等次日)。 +Persistent=true +AccuracySec=1min + +[Install] +WantedBy=timers.target diff --git a/scripts/daily_auto_exchange.py b/scripts/daily_auto_exchange.py new file mode 100644 index 0000000..751e6f6 --- /dev/null +++ b/scripts/daily_auto_exchange.py @@ -0,0 +1,113 @@ +"""0 点自动兑金币(每日定时:把每个用户金币「到分全额」兑成现金)。 + +背景:客户端已删手动「兑金币」入口、改文案「0 点自动兑现金(可能存在延迟)」,故服务端需在 +每天 0 点把金币按汇率(100 金币 = 1 分)兑成现金。复用 exchange_in 流水类型(手动兑入口已下线, +不会与之混淆),幂等键 = 用户当天是否已有 exchange_in 流水。核心逻辑见 +app/repositories/wallet.py::daily_auto_exchange。 + +用法: + # 单轮(给 systemd timer / cron 用,线上每天 0 点一次) + python -m scripts.daily_auto_exchange --once + + # 本地循环(调试用,默认每小时一轮——真到分兑换幂等,空跑无害) + python -m scripts.daily_auto_exchange --loop --interval 3600 + +开关:.env 的 AUTO_EXCHANGE_ENABLED=false 时脚本直接 no-op 退出(免动 systemd timer)。 +带文件锁,防上一轮没跑完下一轮又起。 +""" +from __future__ import annotations + +import argparse +import os +import sys +import tempfile +import time +from datetime import datetime + +# Windows 控制台按 UTF-8 输出中文/¥ +try: + sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined] +except Exception: # noqa: BLE001 + pass + +from app.core.config import settings +from app.db.session import SessionLocal +from app.repositories import wallet as wallet_repo + +# 锁放系统临时目录(任何账号可写),不依赖代码目录 data/ 的写权限。需要指定位置时用环境变量覆盖。 +LOCK_FILE = os.environ.get("DAILY_EXCHANGE_LOCK") or os.path.join( + tempfile.gettempdir(), "daily_auto_exchange.lock" +) +LOCK_STALE_SEC = 30 * 60 # 锁超过 30min 视为陈旧(进程异常退出残留),自动接管 + + +def _acquire_lock() -> bool: + os.makedirs(os.path.dirname(LOCK_FILE) or ".", exist_ok=True) + try: + fd = os.open(LOCK_FILE, os.O_CREAT | os.O_EXCL | os.O_WRONLY) + os.write(fd, f"{os.getpid()} {time.time()}".encode()) + os.close(fd) + return True + except FileExistsError: + try: + with open(LOCK_FILE) as f: + parts = f.read().split() + ts = float(parts[1]) if len(parts) > 1 else 0.0 + if time.time() - ts > LOCK_STALE_SEC: + os.remove(LOCK_FILE) + return _acquire_lock() + except OSError: + pass + return False + + +def _release_lock() -> None: + try: + os.remove(LOCK_FILE) + except OSError: + pass + + +def run_once() -> None: + if not settings.AUTO_EXCHANGE_ENABLED: + print(f"[{datetime.now():%H:%M:%S}] AUTO_EXCHANGE_ENABLED=false,跳过(no-op)") + return + if not _acquire_lock(): + print(f"[{datetime.now():%H:%M:%S}] 上一轮还在跑(锁占用),跳过本轮") + return + t0 = time.time() + try: + with SessionLocal() as db: + stats = wallet_repo.daily_auto_exchange(db) + dt = time.time() - t0 + print( + f"[{datetime.now():%H:%M:%S}] 自动兑完成 用时{dt:.1f}s " + f"扫描{stats['scanned']} 兑换{stats['converted']} " + f"已兑过跳过{stats['skipped_done']} 零头跳过{stats['skipped_dust']} " + f"失败{stats['failed']} 累计兑入{stats['total_cents']}分" + ) + finally: + _release_lock() + + +def main() -> None: + ap = argparse.ArgumentParser(description="0 点自动兑金币") + ap.add_argument("--once", action="store_true", help="只跑一轮(默认)") + ap.add_argument("--loop", action="store_true", help="循环跑(本地调试)") + ap.add_argument("--interval", type=int, default=3600, help="循环间隔秒(默认 3600=1h)") + args = ap.parse_args() + + if args.loop: + print(f"循环模式,每 {args.interval}s 一轮(Ctrl+C 退出)") + try: + while True: + run_once() + time.sleep(args.interval) + except KeyboardInterrupt: + print("已退出") + else: + run_once() + + +if __name__ == "__main__": + main()