diff --git a/app/repositories/wallet.py b/app/repositories/wallet.py index fe1e23e..29b7a2c 100644 --- a/app/repositories/wallet.py +++ b/app/repositories/wallet.py @@ -34,6 +34,10 @@ _WX_STATE_SUCCESS = "SUCCESS" _WX_STATE_FAILED = {"FAIL", "CANCELLED", "CLOSED"} _WX_STATE_WAIT_CONFIRM = "WAIT_USER_CONFIRM" # 用户还没在微信确认页确认 _WITHDRAW_ACTIVE_STATUSES = {"reviewing", "pending"} +# 占用新人档「一次性」资格的提现状态:进行中(reviewing/pending)或成功打款(success)。 +# 被拒/转账失败/解绑退回(rejected/failed,均已退款、钱没到手)不在此列 → 新人档恢复可提 +# (2026-07-16 修正:此前判定不看状态,解绑微信退回后 0.1 被误判已用、资格永久锁死)。 +_NEWBIE_TIER_HELD_STATUSES = {"reviewing", "pending", "success"} # 免确认收款授权状态 _WX_AUTH_ACTIVE = "TAKING_EFFECT" # 已生效,可免确认转账 _WX_AUTH_CLOSED = "CLOSED" # 已关闭(用户/商户/风控),需重新开启 @@ -622,9 +626,10 @@ def _beijing_today_start_utc() -> datetime: def withdraw_tier_states(db: Session, user_id: int, source: str = "coin_cash") -> list[dict]: """福利页(coin_cash)提现档位的可提现状态。withdraw-info 下发与 create_withdraw 校验共用此口径。 - 规则(2026-07-09 拍板,7-9提现ui对齐): - - 新人档(0.1/0.3):账号历史一次性——只要发起过(**任意状态**,含被拒/失败,"发起就算") - 即视为已用,直接**从返回列表消失**;两档各自独立互不影响,不参与"每日选一个额度"互斥。 + 规则(2026-07-09 拍板,7-9提现ui对齐;新人档判定 2026-07-16 修正): + - 新人档(0.1/0.3):账号历史一次性——进行中(reviewing/pending)或成功打款(success)即视为 + 已用,直接**从返回列表消失**;被拒/转账失败/解绑退回(均已退款、钱没到手)则恢复可提,不永久 + 占用资格。两档各自独立互不影响,不参与"每日选一个额度"互斥。 - 常规档(0.5×3 / 10×1 / 20×1):按北京日计次,"发起就算占用"(当天创建的单不论最终状态 都计入,被拒/失败不退当天名额);三档每天只能选一个,选定后其余两档当天 other_tier_selected。 - invite_cash 本轮无档位概念 → 返回空列表(邀请页客户端仍用本地写死档位,行为不变)。 @@ -635,7 +640,7 @@ def withdraw_tier_states(db: Session, user_id: int, source: str = "coin_cash") - tiers = rewards.WITHDRAW_TIERS_COIN_CASH amounts = [t.amount_cents for t in tiers] newbie_amounts = [t.amount_cents for t in tiers if t.is_newbie] - # 新人档历史是否用过:任意时间、任意状态("发起就算") + # 新人档历史是否用过:进行中或已成功打款的单占用资格;被拒/失败/解绑退回(已退款)不算(恢复可提) used_newbie: set[int] = set( db.execute( select(WithdrawOrder.amount_cents) @@ -644,6 +649,7 @@ def withdraw_tier_states(db: Session, user_id: int, source: str = "coin_cash") - WithdrawOrder.user_id == user_id, WithdrawOrder.source == "coin_cash", WithdrawOrder.amount_cents.in_(newbie_amounts), + WithdrawOrder.status.in_(_NEWBIE_TIER_HELD_STATUSES), ) ).scalars() ) if newbie_amounts else set() diff --git a/tests/test_withdraw_tiers.py b/tests/test_withdraw_tiers.py index 73e8de9..214536c 100644 --- a/tests/test_withdraw_tiers.py +++ b/tests/test_withdraw_tiers.py @@ -2,7 +2,8 @@ 规则(2026-07-09 拍板): - 档位 0.1/0.3(新人,历史一次性,免广告)+ 0.5(日3次)/10/20(日1次)。 - - 计次口径"发起就算":当天创建的单不论最终状态(含被拒)都占名额;新人档任何状态都算用过。 + - 常规档计次"发起就算":当天创建的单不论最终状态(含被拒)都占当天名额。 + - 新人档一次性:进行中/成功打款才算用过;被拒/失败/解绑退回(已退款)恢复可提(2026-07-16 修正)。 - 常规三档每天只能选一个;新人档不参与该互斥,两个新人档同天可各提一次。 - invite_cash 无档位概念:tiers 为空、下单不走档位闸(邀请页行为不变)。 wxpay 调用全部 monkeypatch;现金余额 DB 直灌(同 test_withdraw.py 套路)。 @@ -13,7 +14,7 @@ from sqlalchemy import select from app.db.session import SessionLocal from app.models.user import User -from app.models.wallet import CoinAccount +from app.models.wallet import CoinAccount, WithdrawOrder from app.repositories import wallet as crud_wallet @@ -73,6 +74,28 @@ def _tiers(client, token: str, source: str = "coin_cash") -> list[dict]: return r.json()["tiers"] +def _mark_success(bill: str) -> None: + """直接把提现单置 success(模拟审核通过并成功打款,不走 wxpay);测「真拿到钱才永久锁死」。""" + db = SessionLocal() + try: + order = db.execute( + select(WithdrawOrder).where(WithdrawOrder.out_bill_no == bill) + ).scalar_one() + order.status = "success" + db.commit() + finally: + db.close() + + +def _cash_balance(phone: str) -> int: + db = SessionLocal() + try: + user = db.execute(select(User).where(User.phone == phone)).scalar_one() + return db.get(CoinAccount, user.id).cash_balance_cents + finally: + db.close() + + def test_withdraw_info_tiers_full_and_invite_empty(client, monkeypatch) -> None: """新用户 coin_cash 下发 5 档(新人角标齐);invite_cash tiers 为空。""" token = _login(client, "13800006001") @@ -86,8 +109,8 @@ def test_withdraw_info_tiers_full_and_invite_empty(client, monkeypatch) -> None: assert _tiers(client, token, source="invite_cash") == [] -def test_newbie_tiers_independent_and_once_forever(client, monkeypatch) -> None: - """0.1 提过(即使被拒)→ 永久消失;同天 0.3 仍可提;新人档不锁常规档。""" +def test_newbie_tier_restored_on_reject_and_independent(client, monkeypatch) -> None: + """0.1 被拒(已退款)→ 恢复可再提;0.3 独立;新人档不锁常规档。""" _patch_userinfo(monkeypatch, "openid_tier_2") token = _login(client, "13800006002") _seed_balances(client, token, "13800006002", cash=5000) @@ -95,23 +118,62 @@ def test_newbie_tiers_independent_and_once_forever(client, monkeypatch) -> None: r = _withdraw(client, token, 10) # 0.1 新人档 assert r.status_code == 200, r.text - _reject(r.json()["out_bill_no"]) # 被拒也算用过("发起就算") + _reject(r.json()["out_bill_no"]) # 被拒退款 → 钱没到手,不再算用过 tiers = _tiers(client, token) amounts = [t["amount_cents"] for t in tiers] - assert 10 not in amounts # 0.1 消失 - assert 30 in amounts # 0.3 还在,同天仍可提 + assert 10 in amounts # 0.1 恢复(被拒已退款) + assert 30 in amounts # 0.3 独立仍在 # 新人档不参与"选一个额度":常规三档全部仍可提 regular = {t["amount_cents"]: t for t in tiers if not t["is_newbie"]} assert all(regular[a]["available"] for a in (50, 1000, 2000)) - r = _withdraw(client, token, 30) # 同天 0.3 照提 - assert r.status_code == 200, r.text - _reject(r.json()["out_bill_no"]) - - # 0.1 已用过,重提 → 非法金额(档位已消失) + # 0.1 恢复后可再次发起 r = _withdraw(client, token, 10) - assert r.status_code == 400, r.text + assert r.status_code == 200, r.text + + +def test_newbie_tier_locked_forever_after_success(client, monkeypatch) -> None: + """0.1 成功打款(success,真拿到钱)→ 永久消失,重提非法金额(一次性仍成立)。""" + _patch_userinfo(monkeypatch, "openid_tier_ok") + token = _login(client, "13800006011") + _seed_balances(client, token, "13800006011", cash=5000) + client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token)) + + r = _withdraw(client, token, 10) + assert r.status_code == 200, r.text + _mark_success(r.json()["out_bill_no"]) # 真拿到钱 → 一次性用掉 + + assert 10 not in [t["amount_cents"] for t in _tiers(client, token)] # 永久消失 + r = _withdraw(client, token, 10) + assert r.status_code == 400, r.text # 档位已消失 + + +def test_newbie_tier_restored_after_unbind_refund(client, monkeypatch) -> None: + """审核中解绑微信→待审核 0.1 退回现金→新人档恢复可提(本次修复的核心场景)。""" + phone = "13800006012" + _patch_userinfo(monkeypatch, "openid_unbind") + token = _login(client, phone) + _seed_balances(client, token, phone, cash=5000) + client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token)) + + r = _withdraw(client, token, 10) # 0.1 → reviewing,现金扣至 4990 + assert r.status_code == 200, r.text + assert _cash_balance(phone) == 4990 + assert 10 not in [t["amount_cents"] for t in _tiers(client, token)] # 占用中,消失 + + # 审核中解绑(force):待审核单退回现金 + 置 failed + r = client.post("/api/v1/wallet/unbind-wechat", json={"force": True}, headers=_auth(token)) + assert r.status_code == 200, r.text + assert r.json()["bound"] is False + + assert _cash_balance(phone) == 5000 # 现金退回 + assert 10 in [t["amount_cents"] for t in _tiers(client, token)] # 0.1 恢复 + + # 重新绑微信后可再次发起 0.1 + client.post("/api/v1/wallet/bind-wechat", json={"code": "c2"}, headers=_auth(token)) + r = _withdraw(client, token, 10) + assert r.status_code == 200, r.text def test_regular_daily_select_one_tier(client, monkeypatch) -> None: