Compare commits

...

1 Commits

Author SHA1 Message Date
OuYingJun1024 26e582897b feat(wallet): 0 点定时把用户金币「到分全额」自动兑成现金
客户端已删手动兑入口、改「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>
2026-06-11 23:05:40 +08:00
7 changed files with 304 additions and 7 deletions
+2
View File
@@ -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 回调本服务发金币(客户端不参与发奖)。
+4
View File
@@ -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 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。
+79 -7
View File
@@ -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,
+59
View File
@@ -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 的人部署。
+33
View File
@@ -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
+14
View File
@@ -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
+113
View File
@@ -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()