Files
shaguabijia-app-server/scripts/daily_auto_exchange.py
T
ouzhou 57ddcd356b feat(wallet): 0 点定时把用户金币「到分全额」自动兑成现金 (#45)
客户端已删手动兑入口、改「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) <noreply@anthropic.com>

---------

Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #45
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-11 23:36:26 +08:00

114 lines
3.9 KiB
Python

"""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()