07c0b7502c
提现人工审核(提现不再即时打款): - models/wallet.py: WithdrawOrder 状态机改为 reviewing →(审核通过)→ pending → success/failed;reviewing →(驳回)→ rejected(已退款), 默认态 pending → reviewing(扣现金建单但不打款);新增 user_name 列(实名, 微信达额转账要求) - admin/routers/withdraw.py + admin/schemas/wallet.py: 运营后台审核接口(通过触发微信转账 / 驳回退款) - api/v1/wallet.py + repositories/wallet.py: 发起提现进 reviewing 态, 驳回退回现金并写 withdraw_refund - schemas/welfare.py: 提现出参增 reviewing/rejected 状态 + fail_reason 看广告每日时长限流(防刷主闸 + 次数兜底): - 新增 models/ad_watch_log.py: 按 (user_id, watch_date) 聚合当日观看秒数 - 新增 repositories/ad_watch.py: watched_seconds_today / add_watch_seconds(夹 [0, MAX_SINGLE_WATCH_SECONDS]) - api/v1/ad.py: 新增 POST /ad/watch-report(JWT 取 user, 落时长返当日累计); /ad/reward-status 增 watched_seconds_today / limit / remaining - schemas/ad.py: WatchReportIn/Out - core/rewards.py: 新增 DAILY_AD_WATCH_SECONDS_LIMIT=50 分钟(主闸)+ MAX_SINGLE_WATCH_SECONDS=120; DAILY_AD_REWARD_LIMIT 20→200 改作次数兜底(防前端少报时长绕过时长闸, 又不误伤看短广告的正常用户) - repositories/ad_reward.py + models/__init__.py: 接入新表与时长闸 alembic: withdraw_review_and_ad_watch 迁移(withdraw_order.user_name 列 + ad_watch_log 表) tests: 补 test_ad_reward / test_withdraw Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: pure <pure@192.168.0.104> Reviewed-on: #15
572 lines
21 KiB
Python
572 lines
21 KiB
Python
"""钱包 CRUD:账户读取 + 金币入账(共享给签到 / 任务)+ 流水查询。
|
|
|
|
`grant_coins` 是所有"发金币"的唯一入口:它更新余额快照并写一笔流水,但**不 commit**,
|
|
由调用方在同一事务里 commit(签到要同时写 signin_record,任务要同时写 user_task,
|
|
保证"记录"和"加金币"原子化,不会只发钱不留痕或反之)。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from sqlalchemy import select, update
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core import rewards
|
|
from app.core.rewards import COIN_PER_CENT, coins_to_cents
|
|
from app.integrations import wxpay
|
|
from app.models.user import User
|
|
from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder
|
|
|
|
# 微信转账终态:成功 / 失败(失败/取消/关闭都退款)
|
|
_WX_STATE_SUCCESS = "SUCCESS"
|
|
_WX_STATE_FAILED = {"FAIL", "CANCELLED", "CLOSED"}
|
|
_WX_STATE_WAIT_CONFIRM = "WAIT_USER_CONFIRM" # 用户还没在微信确认页确认
|
|
|
|
|
|
class InvalidExchangeAmountError(Exception):
|
|
"""兑换金币数非法(<最小额 或 不是整分倍数)。"""
|
|
|
|
|
|
class InsufficientCoinError(Exception):
|
|
"""金币余额不足。"""
|
|
|
|
|
|
class InvalidWithdrawAmountError(Exception):
|
|
"""提现金额超出允许范围。"""
|
|
|
|
|
|
class WechatNotBoundError(Exception):
|
|
"""用户未绑定微信(无 openid),无法提现。"""
|
|
|
|
|
|
class WechatAlreadyBoundError(Exception):
|
|
"""该微信(openid)已绑定到其他账号。"""
|
|
|
|
|
|
class InsufficientCashError(Exception):
|
|
"""现金余额不足。"""
|
|
|
|
|
|
class WithdrawTransferError(Exception):
|
|
"""调用微信转账失败(已退回余额)。"""
|
|
|
|
|
|
class WithdrawOrderNotFound(Exception):
|
|
"""提现单不存在。"""
|
|
|
|
|
|
class WithdrawNotReviewable(Exception):
|
|
"""提现单当前状态不可审核(非 reviewing,可能已被处理过)。"""
|
|
|
|
|
|
def get_or_create_account(db: Session, user_id: int, *, commit: bool = True) -> CoinAccount:
|
|
"""取用户金币账户,不存在则建一个空账户。"""
|
|
acc = db.get(CoinAccount, user_id)
|
|
if acc is None:
|
|
acc = CoinAccount(
|
|
user_id=user_id,
|
|
coin_balance=0,
|
|
cash_balance_cents=0,
|
|
total_coin_earned=0,
|
|
)
|
|
db.add(acc)
|
|
if commit:
|
|
db.commit()
|
|
db.refresh(acc)
|
|
else:
|
|
db.flush()
|
|
return acc
|
|
|
|
|
|
def grant_coins(
|
|
db: Session,
|
|
user_id: int,
|
|
amount: int,
|
|
*,
|
|
biz_type: str,
|
|
ref_id: str | None = None,
|
|
remark: str | None = None,
|
|
) -> tuple[CoinAccount, CoinTransaction]:
|
|
"""金币变动入口(正数入账 / 负数出账)。更新余额 + 写流水,不 commit。
|
|
|
|
返回 (account, transaction)。调用方负责 commit。
|
|
"""
|
|
acc = get_or_create_account(db, user_id, commit=False)
|
|
acc.coin_balance += amount
|
|
if amount > 0:
|
|
acc.total_coin_earned += amount
|
|
|
|
txn = CoinTransaction(
|
|
user_id=user_id,
|
|
amount=amount,
|
|
balance_after=acc.coin_balance,
|
|
biz_type=biz_type,
|
|
ref_id=ref_id,
|
|
remark=remark,
|
|
)
|
|
db.add(txn)
|
|
db.flush()
|
|
return acc, txn
|
|
|
|
|
|
def list_coin_transactions(
|
|
db: Session,
|
|
user_id: int,
|
|
*,
|
|
limit: int = 20,
|
|
cursor: int | None = None,
|
|
) -> tuple[list[CoinTransaction], int | None]:
|
|
"""金币流水分页(按 id 倒序,游标式)。
|
|
|
|
cursor 为上一页最后一条的 id;返回 (本页列表, next_cursor)。
|
|
next_cursor 为 None 表示没有下一页。
|
|
"""
|
|
stmt = select(CoinTransaction).where(CoinTransaction.user_id == user_id)
|
|
if cursor is not None:
|
|
stmt = stmt.where(CoinTransaction.id < cursor)
|
|
stmt = stmt.order_by(CoinTransaction.id.desc()).limit(limit)
|
|
|
|
items = list(db.execute(stmt).scalars().all())
|
|
next_cursor = items[-1].id if len(items) == limit else None
|
|
return items, next_cursor
|
|
|
|
|
|
def exchange_coins_to_cash(
|
|
db: Session, user_id: int, coin_amount: int
|
|
) -> tuple[CoinAccount, int]:
|
|
"""金币兑现金。返回 (account, 本次兑入的分)。
|
|
|
|
校验:金额 >= MIN_EXCHANGE_COIN 且为整分倍数(InvalidExchangeAmountError),
|
|
余额充足(InsufficientCoinError)。扣金币 + 加现金,两条流水同事务 commit。
|
|
"""
|
|
if coin_amount < rewards.get_min_exchange_coin(db) or coin_amount % COIN_PER_CENT != 0:
|
|
raise InvalidExchangeAmountError
|
|
|
|
acc = get_or_create_account(db, user_id, commit=False)
|
|
if acc.coin_balance < coin_amount:
|
|
raise InsufficientCoinError
|
|
|
|
cents = coins_to_cents(coin_amount)
|
|
remark = f"{coin_amount}金币兑{cents}分"
|
|
|
|
# 1. 扣金币(写 coin_transaction)
|
|
grant_coins(db, user_id, -coin_amount, biz_type="exchange_out", remark=remark)
|
|
# 2. 加现金(写 cash_transaction)
|
|
acc.cash_balance_cents += cents
|
|
db.add(
|
|
CashTransaction(
|
|
user_id=user_id,
|
|
amount_cents=cents,
|
|
balance_after_cents=acc.cash_balance_cents,
|
|
biz_type="exchange_in",
|
|
remark=remark,
|
|
)
|
|
)
|
|
db.commit()
|
|
db.refresh(acc)
|
|
return acc, cents
|
|
|
|
|
|
def list_cash_transactions(
|
|
db: Session,
|
|
user_id: int,
|
|
*,
|
|
limit: int = 20,
|
|
cursor: int | None = None,
|
|
) -> tuple[list[CashTransaction], int | None]:
|
|
"""现金流水分页(按 id 倒序,游标式)。"""
|
|
stmt = select(CashTransaction).where(CashTransaction.user_id == user_id)
|
|
if cursor is not None:
|
|
stmt = stmt.where(CashTransaction.id < cursor)
|
|
stmt = stmt.order_by(CashTransaction.id.desc()).limit(limit)
|
|
|
|
items = list(db.execute(stmt).scalars().all())
|
|
next_cursor = items[-1].id if len(items) == limit else None
|
|
return items, next_cursor
|
|
|
|
|
|
# ===== 提现(现金 → 微信零钱) =====
|
|
|
|
|
|
def bind_wechat_openid(db: Session, user_id: int, code: str) -> dict:
|
|
"""用微信授权 code 换 openid + 昵称头像并绑定到用户。失败抛 ValueError。
|
|
返回 {openid, nickname, avatar_url}(昵称头像可能为 None,见 wxpay.code_to_userinfo)。"""
|
|
info = wxpay.code_to_userinfo(code) # 失败抛 ValueError
|
|
user = db.get(User, user_id)
|
|
if user is None:
|
|
raise WithdrawOrderNotFound # 理论上当前用户必存在
|
|
|
|
# #5 一个微信只能绑一个账号:若该 openid 已属于别的用户,拒绝(防多账号薅羊毛/重复提现)
|
|
other = db.execute(
|
|
select(User.id).where(User.wechat_openid == info["openid"], User.id != user_id)
|
|
).scalar_one_or_none()
|
|
if other is not None:
|
|
raise WechatAlreadyBoundError
|
|
|
|
user.wechat_openid = info["openid"]
|
|
user.wechat_nickname = info["nickname"]
|
|
user.wechat_avatar_url = info["avatar_url"]
|
|
db.commit()
|
|
return info
|
|
|
|
|
|
def unbind_wechat_openid(db: Session, user_id: int) -> None:
|
|
"""解绑微信:清空 openid。已绑定才清,未绑定为幂等空操作。"""
|
|
user = db.get(User, user_id)
|
|
if user is None:
|
|
raise WithdrawOrderNotFound
|
|
if user.wechat_openid is not None:
|
|
user.wechat_openid = None
|
|
user.wechat_nickname = None
|
|
user.wechat_avatar_url = None
|
|
db.commit()
|
|
|
|
|
|
_OUT_BILL_NO_RE = re.compile(r"^[0-9A-Za-z_-]{8,32}$")
|
|
|
|
|
|
def _try_deduct_cash(db: Session, user_id: int, amount_cents: int) -> bool:
|
|
"""原子扣减现金:仅当余额足够时扣,返回是否成功。
|
|
|
|
用带条件的 UPDATE(`WHERE cash_balance_cents >= amount`)避免"读-判断-写"竞态——
|
|
并发/重试时不会两次都通过余额检查导致超额扣款(SQLite 串行写、Postgres 行级,均安全)。
|
|
"""
|
|
res = db.execute(
|
|
update(CoinAccount)
|
|
.where(
|
|
CoinAccount.user_id == user_id,
|
|
CoinAccount.cash_balance_cents >= amount_cents,
|
|
)
|
|
.values(cash_balance_cents=CoinAccount.cash_balance_cents - amount_cents)
|
|
)
|
|
return res.rowcount == 1
|
|
|
|
|
|
def _add_cash(db: Session, user_id: int, amount_cents: int) -> int:
|
|
"""原子增加现金(退款用),返回加后余额。"""
|
|
db.execute(
|
|
update(CoinAccount)
|
|
.where(CoinAccount.user_id == user_id)
|
|
.values(cash_balance_cents=CoinAccount.cash_balance_cents + amount_cents)
|
|
)
|
|
db.flush()
|
|
bal = db.execute(
|
|
select(CoinAccount.cash_balance_cents).where(CoinAccount.user_id == user_id)
|
|
).scalar_one()
|
|
return bal
|
|
|
|
|
|
def _refund_withdraw(
|
|
db: Session, order: WithdrawOrder, reason: str, *, final_status: str = "failed"
|
|
) -> None:
|
|
"""退回现金 + 写 withdraw_refund 流水 + 单子置终态。幂等:已是退款终态不重复退。
|
|
|
|
final_status:
|
|
- "failed" (默认):微信转账失败/取消 → 自动退款
|
|
- "rejected":管理员审核拒绝 → 退款(走 reject_withdraw)
|
|
两种都已退款,用 in 判定防重复退(并发/对账/拒绝重复点)。
|
|
"""
|
|
if order.status in ("failed", "rejected"):
|
|
return # 防重复退款(并发/对账与查单/重复拒绝同时触发)
|
|
bal = _add_cash(db, order.user_id, order.amount_cents)
|
|
db.add(
|
|
CashTransaction(
|
|
user_id=order.user_id,
|
|
amount_cents=order.amount_cents,
|
|
balance_after_cents=bal,
|
|
biz_type="withdraw_refund",
|
|
ref_id=order.out_bill_no,
|
|
# 用户可见文案区分"未成功(自动退)"vs"审核未通过";技术原因记在 order.fail_reason
|
|
remark="提现审核未通过,金额已退回" if final_status == "rejected" else "提现未成功,金额已退回",
|
|
)
|
|
)
|
|
order.status = final_status
|
|
order.fail_reason = reason[:256]
|
|
db.commit()
|
|
|
|
|
|
def _wx_not_found(result: dict) -> bool:
|
|
"""微信查单/转账响应是否表示'此单不存在'(说明转账从未创建,退款安全)。"""
|
|
if result.get("status_code") == 404:
|
|
return True
|
|
data = result.get("data")
|
|
code = data.get("code", "") if isinstance(data, dict) else ""
|
|
return "NOT_FOUND" in str(code)
|
|
|
|
|
|
def _settle_after_ambiguous(db: Session, order: WithdrawOrder, reason: str) -> None:
|
|
"""转账调用结果不明(超时/异常/非200)时,**先查单再决定**,绝不盲目退款(防退款后又到账)。
|
|
- 微信查到 SUCCESS → 钱已出,置 success,不退款
|
|
- 微信查到 FAIL/取消/关闭 → 退款
|
|
- 微信查到在途/待确认 → 保持 pending(带上 package_info/state),交给查单/对账后续处理
|
|
- 微信明确无此单 → 从未创建,退款安全
|
|
- 查询本身也失败 → 保持 pending,留给对账任务(reconcile)兜底,绝不退款
|
|
"""
|
|
try:
|
|
q = wxpay.query_transfer(order.out_bill_no)
|
|
except Exception: # noqa: BLE001 — 查询都失败,保持 pending 等对账
|
|
order.fail_reason = (reason + " | 查单异常,保持pending")[:256]
|
|
db.commit()
|
|
return
|
|
|
|
if q["status_code"] != 200:
|
|
if _wx_not_found(q):
|
|
_refund_withdraw(db, order, reason=reason + " (微信无此单,已退回)")
|
|
else:
|
|
order.fail_reason = (reason + " | 查单非200,保持pending")[:256]
|
|
db.commit()
|
|
return
|
|
|
|
state = q["data"].get("state", "")
|
|
order.wechat_state = state
|
|
if state == _WX_STATE_SUCCESS:
|
|
order.status = "success"
|
|
order.transfer_bill_no = q["data"].get("transfer_bill_no")
|
|
db.commit()
|
|
elif state in _WX_STATE_FAILED:
|
|
_refund_withdraw(db, order, reason=reason)
|
|
else:
|
|
order.package_info = q["data"].get("package_info") or order.package_info
|
|
db.commit()
|
|
|
|
|
|
def create_withdraw(
|
|
db: Session,
|
|
user_id: int,
|
|
amount_cents: int,
|
|
*,
|
|
user_name: str | None = None,
|
|
out_bill_no: str | None = None,
|
|
) -> WithdrawOrder:
|
|
"""发起提现:原子扣款 + 建单 reviewing(待人工审核),**不打款**。
|
|
|
|
打款推迟到管理员在 admin 后台审核通过(approve_withdraw → execute_withdraw_transfer)才发起;
|
|
拒绝则退回现金(reject_withdraw)。
|
|
#1 用原子条件 UPDATE 扣款,杜绝并发超额——且"待审核期间钱已扣减",防止用户拿同一笔余额
|
|
重复发起多笔提现(审核拒绝再退回)。
|
|
#2 out_bill_no 客户端幂等键:同号重试返回该单现状(reviewing 等审核),不重复扣款建单。
|
|
实名 user_name 在此存下(WithdrawOrder.user_name),供异步审核打款时传给微信(达额需实名)。
|
|
"""
|
|
if amount_cents < rewards.get_withdraw_min_cents(db) or amount_cents > rewards.get_withdraw_max_cents(db):
|
|
raise InvalidWithdrawAmountError
|
|
|
|
# 提现即要求已绑微信:否则审核通过也打不了款,提前拦更友好
|
|
user = db.get(User, user_id)
|
|
openid = user.wechat_openid if user else None
|
|
if not openid:
|
|
raise WechatNotBoundError
|
|
|
|
# #2 幂等:该单已存在 → 直接返回现状(reviewing 等审核 / 已处理态原样返回),不重复扣款。
|
|
# 不再像旧版对 pending 查微信:本函数不再涉及微信,查单交给 /withdraw/status 与对账任务。
|
|
if out_bill_no and _OUT_BILL_NO_RE.match(out_bill_no):
|
|
existing = db.execute(
|
|
select(WithdrawOrder).where(
|
|
WithdrawOrder.out_bill_no == out_bill_no, WithdrawOrder.user_id == user_id
|
|
)
|
|
).scalar_one_or_none()
|
|
if existing is not None:
|
|
return existing
|
|
else:
|
|
out_bill_no = uuid.uuid4().hex
|
|
|
|
# 账户须存在(原子扣款的 UPDATE 不会建账户)
|
|
get_or_create_account(db, user_id, commit=True)
|
|
|
|
# #1 原子扣款:余额不足时影响行数为 0
|
|
if not _try_deduct_cash(db, user_id, amount_cents):
|
|
db.rollback()
|
|
raise InsufficientCashError
|
|
|
|
bal = db.execute(
|
|
select(CoinAccount.cash_balance_cents).where(CoinAccount.user_id == user_id)
|
|
).scalar_one()
|
|
db.add(
|
|
CashTransaction(
|
|
user_id=user_id,
|
|
amount_cents=-amount_cents,
|
|
balance_after_cents=bal,
|
|
biz_type="withdraw",
|
|
ref_id=out_bill_no,
|
|
remark="提现到微信零钱(待审核)",
|
|
)
|
|
)
|
|
order = WithdrawOrder(
|
|
user_id=user_id,
|
|
out_bill_no=out_bill_no,
|
|
amount_cents=amount_cents,
|
|
user_name=user_name,
|
|
status="reviewing",
|
|
)
|
|
db.add(order)
|
|
db.commit()
|
|
db.refresh(order)
|
|
return order # 待管理员审核;**不在此处打款**
|
|
|
|
|
|
def execute_withdraw_transfer(db: Session, order: WithdrawOrder) -> WithdrawOrder:
|
|
"""审核通过后真正发起微信转账(复用原"转账 + 模糊失败查单"逻辑,绝不盲目退款)。
|
|
|
|
流程:reviewing → 置 pending → 调微信转账 → SUCCESS=success / 失败/取消=退款 failed /
|
|
结果不明=先查单再决定。**不抛异常**(admin 调用方按 order.status 判断结果)。
|
|
用户在审核期间解绑微信 → 退款 failed(打不了款)。
|
|
可能返回 package_info(WAIT_USER_CONFIRM 场景:需用户在 App 端确认页确认后才真正到账)。
|
|
"""
|
|
user = db.get(User, order.user_id)
|
|
openid = user.wechat_openid if user else None
|
|
if not openid:
|
|
_refund_withdraw(db, order, reason="用户已解绑微信,无法打款")
|
|
db.refresh(order)
|
|
return order
|
|
|
|
order.status = "pending" # 进入打款在途(转账调用前崩溃留 pending,交对账兜底)
|
|
db.commit()
|
|
|
|
try:
|
|
result = wxpay.create_transfer(
|
|
openid, order.amount_cents, order.out_bill_no, user_name=order.user_name
|
|
)
|
|
except Exception as e: # noqa: BLE001 — 超时/网络异常≠失败,查单确认
|
|
_settle_after_ambiguous(db, order, reason=f"转账调用异常: {e}")
|
|
db.refresh(order)
|
|
return order
|
|
|
|
if result["status_code"] != 200:
|
|
msg = str(result["data"].get("message") or result["data"])
|
|
_settle_after_ambiguous(db, order, reason=msg)
|
|
db.refresh(order)
|
|
return order
|
|
|
|
data = result["data"]
|
|
order.wechat_state = data.get("state")
|
|
order.transfer_bill_no = data.get("transfer_bill_no")
|
|
order.package_info = data.get("package_info")
|
|
if data.get("state") == _WX_STATE_SUCCESS:
|
|
order.status = "success"
|
|
db.commit()
|
|
db.refresh(order)
|
|
return order
|
|
|
|
|
|
def _get_withdraw_or_raise(db: Session, out_bill_no: str) -> WithdrawOrder:
|
|
"""按 out_bill_no 取提现单(admin 跨用户,不限 user_id)。不存在抛 WithdrawOrderNotFound。"""
|
|
order = db.execute(
|
|
select(WithdrawOrder).where(WithdrawOrder.out_bill_no == out_bill_no)
|
|
).scalar_one_or_none()
|
|
if order is None:
|
|
raise WithdrawOrderNotFound
|
|
return order
|
|
|
|
|
|
def approve_withdraw(db: Session, out_bill_no: str) -> WithdrawOrder:
|
|
"""管理员审核通过:校验 reviewing → 发起微信转账。返回最终 order(pending/success/failed)。
|
|
|
|
幂等防误操作:非 reviewing(已通过/已拒绝/已打款)抛 WithdrawNotReviewable,不会重复打款。
|
|
"""
|
|
order = _get_withdraw_or_raise(db, out_bill_no)
|
|
if order.status != "reviewing":
|
|
raise WithdrawNotReviewable(f"提现单状态为 {order.status},非待审核,不能通过")
|
|
return execute_withdraw_transfer(db, order)
|
|
|
|
|
|
def reject_withdraw(db: Session, out_bill_no: str, reason: str) -> WithdrawOrder:
|
|
"""管理员审核拒绝:校验 reviewing → 退回现金 + 置 rejected。
|
|
|
|
非 reviewing 抛 WithdrawNotReviewable(防对已打款单误退导致重复退款)。
|
|
"""
|
|
order = _get_withdraw_or_raise(db, out_bill_no)
|
|
if order.status != "reviewing":
|
|
raise WithdrawNotReviewable(f"提现单状态为 {order.status},非待审核,不能拒绝")
|
|
_refund_withdraw(db, order, reason=reason or "审核未通过", final_status="rejected")
|
|
db.refresh(order)
|
|
return order
|
|
|
|
|
|
def refresh_withdraw_status(
|
|
db: Session, user_id: int, out_bill_no: str, *, cancel_if_unconfirmed: bool = False
|
|
) -> WithdrawOrder:
|
|
"""查微信单状态并归一化:SUCCESS→success;FAIL/CANCELLED/CLOSED→退款+failed;其余保持 pending。
|
|
|
|
cancel_if_unconfirmed:查到 WAIT_USER_CONFIRM 时,是否当作"用户放弃"撤销并退款。
|
|
- True:用户从确认页返回后查单、或对账老单时传 → 撤销+退款。
|
|
- False(默认):幂等重试只想读状态时传 → 保留 pending,不能误撤用户还想确认的单。
|
|
"""
|
|
order = db.execute(
|
|
select(WithdrawOrder).where(
|
|
WithdrawOrder.out_bill_no == out_bill_no, WithdrawOrder.user_id == user_id
|
|
)
|
|
).scalar_one_or_none()
|
|
if order is None:
|
|
raise WithdrawOrderNotFound
|
|
if order.status != "pending":
|
|
return order # 已终态,不再查
|
|
|
|
result = wxpay.query_transfer(out_bill_no)
|
|
if result["status_code"] != 200:
|
|
if _wx_not_found(result):
|
|
# 微信明确无此单 → 转账从未创建(如崩溃在扣款后/调用前),退款安全
|
|
_refund_withdraw(db, order, reason="微信无此单,已退回")
|
|
db.refresh(order)
|
|
return order # 其他查询失败不改状态,保持 pending 等下次
|
|
|
|
state = result["data"].get("state", "")
|
|
order.wechat_state = state
|
|
if state == _WX_STATE_SUCCESS:
|
|
order.status = "success"
|
|
db.commit()
|
|
elif state in _WX_STATE_FAILED:
|
|
_refund_withdraw(db, order, reason=f"微信转账状态 {state}")
|
|
elif state == _WX_STATE_WAIT_CONFIRM and cancel_if_unconfirmed:
|
|
# 用户从确认页回来了却仍未确认 → 视为放弃:撤销微信单(防事后确认导致重复打款)后退款。
|
|
# 撤单失败(可能已被确认进 ACCEPTED 的竞态)则保持 pending,等下次查询。
|
|
cancel = wxpay.cancel_transfer(out_bill_no)
|
|
if cancel["status_code"] in (200, 202):
|
|
_refund_withdraw(db, order, reason="用户未确认,已撤销提现")
|
|
else:
|
|
db.commit()
|
|
else:
|
|
db.commit() # ACCEPTED / PROCESSING 等真在途,仍 pending
|
|
db.refresh(order)
|
|
return order
|
|
|
|
|
|
def reconcile_pending_withdraws(db: Session, *, older_than_minutes: int = 15) -> dict:
|
|
"""对账:扫描超过 N 分钟仍 pending 的提现单,逐单查微信归一化(成功/退款/撤销未确认)。
|
|
防止"扣了款但转账没发起/没确认"的孤儿单永久锁住用户余额。建议用 cron/systemd timer 定时跑。
|
|
返回 {checked, resolved} 计数。"""
|
|
cutoff = datetime.now(timezone.utc) - timedelta(minutes=older_than_minutes)
|
|
orders = list(
|
|
db.execute(
|
|
select(WithdrawOrder).where(
|
|
WithdrawOrder.status == "pending", WithdrawOrder.created_at < cutoff
|
|
)
|
|
).scalars().all()
|
|
)
|
|
resolved = 0
|
|
for o in orders:
|
|
try:
|
|
# 老单视同"用户已放弃确认",查到 WAIT_USER_CONFIRM 也撤销退款
|
|
refreshed = refresh_withdraw_status(
|
|
db, o.user_id, o.out_bill_no, cancel_if_unconfirmed=True
|
|
)
|
|
if refreshed.status != "pending":
|
|
resolved += 1
|
|
except Exception: # noqa: BLE001 — 单笔失败不影响其余,下轮再试
|
|
db.rollback()
|
|
continue
|
|
return {"checked": len(orders), "resolved": resolved}
|
|
|
|
|
|
def list_withdraw_orders(
|
|
db: Session, user_id: int, *, limit: int = 20, cursor: int | None = None
|
|
) -> tuple[list[WithdrawOrder], int | None]:
|
|
"""提现单分页(按 id 倒序,游标式)。"""
|
|
stmt = select(WithdrawOrder).where(WithdrawOrder.user_id == user_id)
|
|
if cursor is not None:
|
|
stmt = stmt.where(WithdrawOrder.id < cursor)
|
|
stmt = stmt.order_by(WithdrawOrder.id.desc()).limit(limit)
|
|
items = list(db.execute(stmt).scalars().all())
|
|
next_cursor = items[-1].id if len(items) == limit else None
|
|
return items, next_cursor
|