feat(wallet): 解绑微信前对待审核提现二次确认 (#46)
## 改动
解绑微信前若有**待审核(reviewing)**提现单,首次解绑被拦下返回二次确认,用户确认后带 `force=true` 才真解绑。
- `schemas`: `UnbindWechatRequest{force}` + `UnbindWechatResultOut{needs_confirm, message}`
- `repositories`: `has_reviewing_withdraw`(只看 reviewing)
- `api`: `unbind-wechat` 接受可选 body(兼容旧无 body 客户端);`bound` 返回真实绑定态
- `docs/api`: 同步协议
## 为什么只拦 reviewing
reviewing 单解绑后审核打款会回读空 openid → 自动退回现金(钱不丢、只为让用户知情);pending 转账已在途、解绑影响不到,不拦。
配套客户端见 shaguabijia-app-android 同名分支。
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #46
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
This commit was merged in pull request #46.
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
"""add coin_transaction task ref unique index
|
||||
|
||||
可重复任务(task_enable_notification「打开消息提醒」逐次减半领取)的发放路径是无锁的
|
||||
读-算-写:并发/连点的两个 claim 会都读到同一已领次数、算出同一 ref_id(task_key:N)、
|
||||
各发一笔,导致双倍发钱。给 coin_transaction 加 (user_id, biz_type, ref_id) 部分唯一索引
|
||||
(仅 biz_type LIKE 'task%' 且 ref_id 非空),第二笔撞唯一约束 → IntegrityError,被
|
||||
task.claim_task 兜底成 AlreadyClaimedError(409)。与 cash_transaction 提现退款、
|
||||
withdraw_order 在途单的「唯一约束 + IntegrityError 兜底」同模式。
|
||||
|
||||
Revision ID: coin_txn_task_ref_uq
|
||||
Revises: drop_force_onboarding
|
||||
Create Date: 2026-06-12 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "coin_txn_task_ref_uq"
|
||||
down_revision: Union[str, Sequence[str], None] = "drop_force_onboarding"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_index(
|
||||
"ux_coin_transaction_task_ref",
|
||||
"coin_transaction",
|
||||
["user_id", "biz_type", "ref_id"],
|
||||
unique=True,
|
||||
sqlite_where=sa.text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"),
|
||||
postgresql_where=sa.text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ux_coin_transaction_task_ref", table_name="coin_transaction")
|
||||
@@ -13,6 +13,7 @@ from app.admin.audit import write_audit
|
||||
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
||||
from app.admin.schemas.config import ConfigItemOut, ConfigUpdateRequest
|
||||
from app.core.config_schema import CONFIG_DEFS
|
||||
from app.core.rewards import SIGNIN_CYCLE_LEN
|
||||
from app.models.admin import AdminUser
|
||||
from app.repositories import app_config
|
||||
|
||||
@@ -34,8 +35,8 @@ def _validate(key: str, value: Any) -> None:
|
||||
raise ValueError("需为非空整数列表")
|
||||
if not all(isinstance(x, int) and not isinstance(x, bool) and x >= 0 for x in value):
|
||||
raise ValueError("列表元素需为非负整数")
|
||||
if key == "signin_rewards" and len(value) != 14:
|
||||
raise ValueError("签到档位必须正好 14 个(对应 14 天循环)")
|
||||
if key == "signin_rewards" and len(value) != SIGNIN_CYCLE_LEN:
|
||||
raise ValueError(f"签到档位必须正好 {SIGNIN_CYCLE_LEN} 个(对应 {SIGNIN_CYCLE_LEN} 天循环)")
|
||||
elif t == "dict_str_int":
|
||||
if not isinstance(value, dict) or not all(
|
||||
isinstance(k, str) and isinstance(v, int) and not isinstance(v, bool)
|
||||
|
||||
+18
-2
@@ -35,6 +35,7 @@ from app.schemas.welfare import (
|
||||
ExchangeResultOut,
|
||||
TransferAuthResultOut,
|
||||
TransferAuthStatusOut,
|
||||
UnbindWechatRequest,
|
||||
UnbindWechatResultOut,
|
||||
WithdrawInfoOut,
|
||||
WithdrawOrderOut,
|
||||
@@ -151,9 +152,24 @@ def bind_wechat(req: BindWechatRequest, user: CurrentUser, db: DbSession) -> Bin
|
||||
|
||||
|
||||
@router.post("/unbind-wechat", response_model=UnbindWechatResultOut, summary="解绑微信(清空 openid)")
|
||||
def unbind_wechat(user: CurrentUser, db: DbSession) -> UnbindWechatResultOut:
|
||||
def unbind_wechat(
|
||||
user: CurrentUser, db: DbSession, req: UnbindWechatRequest | None = None
|
||||
) -> UnbindWechatResultOut:
|
||||
# 有「待审核(reviewing)」提现单时,首次解绑拦下要求确认:这类单还没打款,确认解绑(force)
|
||||
# 时会被立即退回现金余额(refund_reviewing_withdraws_on_unbind),后台不再挂单,用户需先知情。
|
||||
# (pending 单转账已在途、解绑影响不到,不拦;见 has_reviewing_withdraw。)
|
||||
force = req.force if req is not None else False
|
||||
if not force and crud_wallet.has_reviewing_withdraw(db, user.id):
|
||||
logger.info("unbind wechat needs_confirm(有待审核提现) user_id=%d", user.id)
|
||||
return UnbindWechatResultOut(
|
||||
bound=bool(user.wechat_openid),
|
||||
needs_confirm=True,
|
||||
message="你有提现正在审核中,解绑微信后这笔会自动退回到现金余额。确定解绑吗?",
|
||||
)
|
||||
# 先把待审核提现立即退回现金(无则 no-op),再清 openid —— 让"解绑即退"名副其实
|
||||
refunded = crud_wallet.refund_reviewing_withdraws_on_unbind(db, user.id)
|
||||
crud_wallet.unbind_wechat_openid(db, user.id)
|
||||
logger.info("unbind wechat ok user_id=%d", user.id)
|
||||
logger.info("unbind wechat ok user_id=%d force=%s refunded_withdraws=%d", user.id, force, refunded)
|
||||
return UnbindWechatResultOut(bound=False)
|
||||
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@ from app.core import rewards as r
|
||||
# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int
|
||||
CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
||||
"signin_rewards": {
|
||||
"default": list(r.SIGNIN_REWARDS), "label": "签到 14 天金币档位",
|
||||
"default": list(r.SIGNIN_REWARDS), "label": "签到 7 天金币档位",
|
||||
"group": "签到", "type": "int_list",
|
||||
"help": "第 1~14 天每天签到发的金币;断签重置回第 1 天。长度需为 14。",
|
||||
"help": "第 1~7 天每天签到发的金币;断签重置回第 1 天。长度需为 7。",
|
||||
},
|
||||
"min_exchange_coin": {
|
||||
"default": r.MIN_EXCHANGE_COIN, "label": "最低兑换金币",
|
||||
@@ -63,6 +63,6 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
||||
"signin_boost_coin": {
|
||||
"default": r.SIGNIN_BOOST_COIN, "label": "签到膨胀固定金币",
|
||||
"group": "签到", "type": "int",
|
||||
"help": "Day1-Day13 签到后看完激励视频额外发放的固定金币;Day14 不展示也不允许膨胀。",
|
||||
"help": "Day1-Day6 签到后看完激励视频额外发放的固定金币;Day7 不展示也不允许膨胀。",
|
||||
},
|
||||
}
|
||||
|
||||
+34
-7
@@ -17,11 +17,10 @@ def cn_today() -> date:
|
||||
return datetime.now(CN_TZ).date()
|
||||
|
||||
|
||||
# ===== 签到:14 天循环,断签重置回第 1 天 =====
|
||||
# 第 1→14 天的金币奖励,签到逻辑用 cycle_day(1..14)索引。
|
||||
# ===== 签到:7 天循环,断签重置回第 1 天(原型 2026-06 由 14 天改 7 天一轮)=====
|
||||
# 第 1→7 天的金币奖励,签到逻辑用 cycle_day(1..7)索引;一轮 7 天累计 2500 金币(对原型 banner)。
|
||||
SIGNIN_REWARDS: tuple[int, ...] = (
|
||||
200, 120, 150, 180, 200, 250, 500,
|
||||
200, 250, 300, 350, 400, 500, 3000,
|
||||
200, 200, 300, 200, 400, 400, 800,
|
||||
)
|
||||
SIGNIN_CYCLE_LEN: int = len(SIGNIN_REWARDS)
|
||||
|
||||
@@ -57,13 +56,40 @@ WITHDRAW_MAX_CENTS: int = 5_000_000 # 5 万元
|
||||
TASK_ENABLE_NOTIFICATION = "enable_notification"
|
||||
|
||||
# task_key -> 奖励金币
|
||||
# 打开消息提醒: 1000 金币(=¥0.1, 客户端原型展示口径; 量级与签到/里程碑相称)。
|
||||
# 打开消息提醒: 750 金币(客户端原型展示口径 2026-06 调 1000→750; 量级与签到/里程碑相称)。
|
||||
# 注意: 不再 = 兑换下限(下限已降到 MIN_EXCHANGE_COIN=COIN_PER_CENT=100), test_exchange_flow 改走 grant_coins 直接供款。
|
||||
# ⚠️打开消息提醒是「可重复领取」任务(2026-06):每次开启通知发一次,金额逐次减半(见 notification_reward),
|
||||
# 这里的 750 是首次(基数)。客户端只在「通知权限关闭」时展示该任务,开着就隐藏。
|
||||
TASK_REWARDS: dict[str, int] = {
|
||||
TASK_ENABLE_NOTIFICATION: 1000,
|
||||
TASK_ENABLE_NOTIFICATION: 750,
|
||||
}
|
||||
|
||||
|
||||
def notification_reward(base: int, times_claimed: int) -> int:
|
||||
"""打开消息提醒第 (times_claimed+1) 次领取的金币。
|
||||
|
||||
基数 base(=750)起,每次减半、四舍五入到整数、最低 1:
|
||||
750 → 375 → 188 → 94 → 47 → 24 → 12 → 6 → 3 → 2 → 1 → 1 …
|
||||
(四舍五入用 (x+1)//2,匹配产品给的 750/375/188/94 序列。)
|
||||
"""
|
||||
r = max(1, base)
|
||||
for _ in range(max(0, times_claimed)):
|
||||
r = max(1, (r + 1) // 2)
|
||||
return r
|
||||
|
||||
|
||||
def notification_max_claims(base: int) -> int:
|
||||
"""打开消息提醒最多可领取的次数:减半到最低 1 那次为止(含),之后不再可领。
|
||||
|
||||
防止「通知一直关着」时无限刷最低档 1 金币(发放上限闸,服务端硬限,不依赖客户端只在
|
||||
通知关闭时才展示的约定)。base=750 时序列 750→…→1 在第 11 次落到 1,故上限 11 次。
|
||||
"""
|
||||
n = 0
|
||||
while notification_reward(base, n) > 1:
|
||||
n += 1
|
||||
return n + 1 # 含第一次减半到 1 的那一次
|
||||
|
||||
|
||||
# ===== 比价战绩里程碑(累计成功比价 N 次,逐档解锁领金币)=====
|
||||
# 第 1→6 次的金币奖励(1-based:第 N 次比价解锁第 N 档)。值沿用客户端原型档位。
|
||||
# 数据源是 comparison_record 里 status='success' 的条数;每档领一次,
|
||||
@@ -167,7 +193,8 @@ def calculate_ad_reward_coin(ecpm: str | int | float | None, today_count_after_t
|
||||
return max(0, round(yuan * COIN_PER_YUAN))
|
||||
|
||||
|
||||
SIGNIN_BOOST_COIN: int = 2000
|
||||
# 签到看广告膨胀:S2S 固定补发(原型 2026-06 由 2000 提到 3000,对应 CTA「看广告最高膨胀至3000金币」)。
|
||||
SIGNIN_BOOST_COIN: int = 3000
|
||||
|
||||
|
||||
# ===== 看激励视频发金币(穿山甲 S2S 服务端回调发奖)=====
|
||||
|
||||
@@ -41,6 +41,21 @@ class CoinAccount(Base):
|
||||
|
||||
class CoinTransaction(Base):
|
||||
__tablename__ = "coin_transaction"
|
||||
__table_args__ = (
|
||||
# 可重复任务(如 task_enable_notification)按 ref_id 序号(task_key:N)去重,挡并发/连点
|
||||
# 重复发放——读-算-写无锁,两个并发 claim 会都算出同一序号双倍发钱(见 task.claim_task)。
|
||||
# 仅 task_* 且 ref_id 非空生效;一次性任务 ref_id=task_key 本就每人一条,纳入无害;
|
||||
# 不影响 signin / exchange / withdraw 等其它 biz_type。
|
||||
Index(
|
||||
"ux_coin_transaction_task_ref",
|
||||
"user_id",
|
||||
"biz_type",
|
||||
"ref_id",
|
||||
unique=True,
|
||||
sqlite_where=text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"),
|
||||
postgresql_where=text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
|
||||
@@ -57,19 +57,19 @@ def _phone_name(rng: random.Random) -> str:
|
||||
|
||||
|
||||
def _nickname(rng: random.Random) -> str:
|
||||
"""昵称打码式,中文为主(约 11/12),掺少量中英混 / 纯字母。"""
|
||||
"""昵称打码式,中文为主(约 11/12),掺少量中英混 / 纯字母。
|
||||
⚠️ 脱敏星号一律夹在中间(xx**yy),星号前后都要有可见字——不出现结尾打码(省钱**)的形态。
|
||||
"""
|
||||
p = rng.randint(0, 11)
|
||||
if p in (0, 1, 2):
|
||||
return rng.choice(_NICK_WORDS) + "**" # 省钱**
|
||||
if p in (3, 4):
|
||||
return rng.choice(_NICK_WORDS) + "**" + rng.choice(_NICK_SUFFIX) # 吃货**达人
|
||||
if p in (0, 1, 2, 3, 4):
|
||||
return rng.choice(_NICK_WORDS) + "**" + rng.choice(_NICK_SUFFIX) # 省钱**党 / 吃货**达人
|
||||
if p in (5, 6):
|
||||
return rng.choice(_SURNAMES) + "**" # 张**
|
||||
return rng.choice(_SURNAMES) + "**" + rng.choice(_NICK_SUFFIX) # 张**侠
|
||||
if p in (7, 8):
|
||||
return rng.choice(_NICK_PREFIX) + rng.choice(_SURNAMES) + "**" # 小张**
|
||||
return rng.choice(_NICK_PREFIX) + rng.choice(_SURNAMES) + "**" + rng.choice(_NICK_SUFFIX) # 小张**达人
|
||||
if p in (9, 10):
|
||||
return rng.choice(_NICK_WORDS) + rng.choice(_LETTER_U) + "**" # 中英混 省钱A**
|
||||
return rng.choice(_LETTER_U) + "**" + rng.choice(_LETTER_L) # 纯字母(约 1/12)
|
||||
return rng.choice(_NICK_WORDS) + "**" + rng.choice(_LETTER_U) # 中英混 省钱**A(字母移到星号后)
|
||||
return rng.choice(_LETTER_U) + "**" + rng.choice(_LETTER_L) # 纯字母(约 1/12) A**b
|
||||
|
||||
|
||||
def _realistic_name(rng: random.Random) -> str:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""签到 CRUD:状态查询 + 执行签到。
|
||||
|
||||
14 天循环规则:
|
||||
- 昨天签过 → 今天 cycle_day = 昨天 % 14 + 1,streak += 1(连续)
|
||||
7 天循环规则(2026-06 由 14 天改 7 天一轮,周期长度统一取 SIGNIN_CYCLE_LEN):
|
||||
- 昨天签过 → 今天 cycle_day = 昨天 % SIGNIN_CYCLE_LEN + 1,streak += 1(连续)
|
||||
- 否则(首签 / 断签) → cycle_day = 1,streak = 1(重置)
|
||||
今天的 cycle_day 决定发多少金币(档位来自 rewards.get_signin_rewards,运营后台可改)。
|
||||
"""
|
||||
@@ -33,7 +33,7 @@ class AlreadyBoostedError(Exception):
|
||||
|
||||
|
||||
class LastCycleDayBoostBlockedError(Exception):
|
||||
"""14 天循环最后一天不允许签到膨胀。"""
|
||||
"""循环最后一天(第 SIGNIN_CYCLE_LEN 天)不允许签到膨胀。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -77,7 +77,8 @@ def get_status(db: Session, user_id: int) -> SigninStatus:
|
||||
today_signed = last is not None and last.signin_date == today
|
||||
|
||||
if today_signed:
|
||||
today_cycle_day = last.cycle_day
|
||||
# 14→7 改制遗留:老记录 cycle_day 可能 >SIGNIN_CYCLE_LEN,归一化到 1..LEN 防 rewards_list 越界
|
||||
today_cycle_day = (last.cycle_day - 1) % SIGNIN_CYCLE_LEN + 1
|
||||
consecutive_days = last.streak
|
||||
else:
|
||||
today_cycle_day = _next_cycle_day(last, today)
|
||||
|
||||
@@ -7,14 +7,31 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
from app.models.task import UserTask
|
||||
from app.models.wallet import CoinTransaction
|
||||
from app.repositories import wallet as crud_wallet
|
||||
|
||||
|
||||
def _notification_grant_times(db: Session, user_id: int) -> int:
|
||||
"""打开消息提醒已领取次数 = 该 biz_type 的【正向】金币流水条数(可重复任务不写 user_task)。
|
||||
|
||||
限 amount>0 只数发放笔,避免日后出现该 biz_type 的回滚/冲正负向流水时把次数算大、减半档位错。
|
||||
"""
|
||||
biz = f"task_{rewards.TASK_ENABLE_NOTIFICATION}"
|
||||
return db.execute(
|
||||
select(func.count()).select_from(CoinTransaction).where(
|
||||
CoinTransaction.user_id == user_id,
|
||||
CoinTransaction.biz_type == biz,
|
||||
CoinTransaction.amount > 0,
|
||||
)
|
||||
).scalar_one() or 0
|
||||
|
||||
|
||||
class UnknownTaskError(Exception):
|
||||
"""task_key 不在已知任务表里。"""
|
||||
|
||||
@@ -31,14 +48,30 @@ class TaskState:
|
||||
|
||||
|
||||
def list_tasks(db: Session, user_id: int) -> list[TaskState]:
|
||||
"""返回所有已知一次性任务及其领取状态。"""
|
||||
"""返回所有已知任务及其领取状态。
|
||||
|
||||
打开消息提醒是可重复任务:claimed 恒 False,coin = 下一次(减半后)的金额;
|
||||
客户端按「通知权限是否开启」决定展不展示(开着就隐藏,不在这里判断)。
|
||||
其余为一次性任务,user_task 唯一约束去重。
|
||||
"""
|
||||
task_rewards = rewards.get_task_rewards(db)
|
||||
stmt = select(UserTask.task_key).where(UserTask.user_id == user_id)
|
||||
claimed_keys = set(db.execute(stmt).scalars().all())
|
||||
return [
|
||||
TaskState(task_key=key, coin=coin, claimed=key in claimed_keys)
|
||||
for key, coin in task_rewards.items()
|
||||
]
|
||||
result: list[TaskState] = []
|
||||
for key, coin in task_rewards.items():
|
||||
if key == rewards.TASK_ENABLE_NOTIFICATION:
|
||||
times = _notification_grant_times(db, user_id)
|
||||
exhausted = times >= rewards.notification_max_claims(coin)
|
||||
result.append(
|
||||
TaskState(
|
||||
task_key=key,
|
||||
coin=rewards.notification_reward(coin, times),
|
||||
claimed=exhausted, # 领满(减半到底)后置 True,客户端据此可隐藏
|
||||
)
|
||||
)
|
||||
else:
|
||||
result.append(TaskState(task_key=key, coin=coin, claimed=key in claimed_keys))
|
||||
return result
|
||||
|
||||
|
||||
def claim_task(db: Session, user_id: int, task_key: str) -> tuple[int, int]:
|
||||
@@ -50,6 +83,27 @@ def claim_task(db: Session, user_id: int, task_key: str) -> tuple[int, int]:
|
||||
if task_key not in task_rewards:
|
||||
raise UnknownTaskError
|
||||
|
||||
# 打开消息提醒:可重复领取,每次减半。不写 user_task(不去重),已领次数用金币流水条数算;
|
||||
# ref_id 带序号(task_key:N)让流水可读、且 (user_id,biz_type,ref_id) 唯一索引能挡并发/连点
|
||||
# 重复发放。减半到底(notification_max_claims)后不再可领。客户端只在通知权限关闭时展示+触发。
|
||||
if task_key == rewards.TASK_ENABLE_NOTIFICATION:
|
||||
base = task_rewards[task_key]
|
||||
times = _notification_grant_times(db, user_id)
|
||||
if times >= rewards.notification_max_claims(base):
|
||||
raise AlreadyClaimedError
|
||||
coin = rewards.notification_reward(base, times)
|
||||
try:
|
||||
acc, _ = crud_wallet.grant_coins(
|
||||
db, user_id, coin,
|
||||
biz_type=f"task_{task_key}", ref_id=f"{task_key}:{times + 1}",
|
||||
)
|
||||
db.commit()
|
||||
except IntegrityError as e:
|
||||
# 并发/连点:另一个请求已抢先发了同一序号(撞唯一索引)。回滚,按已领处理,不双发。
|
||||
db.rollback()
|
||||
raise AlreadyClaimedError from e
|
||||
return coin, acc.coin_balance
|
||||
|
||||
existing = db.execute(
|
||||
select(UserTask).where(
|
||||
UserTask.user_id == user_id, UserTask.task_key == task_key
|
||||
|
||||
@@ -334,6 +334,26 @@ def bind_wechat_openid(db: Session, user_id: int, code: str) -> dict:
|
||||
return info
|
||||
|
||||
|
||||
def has_reviewing_withdraw(db: Session, user_id: int) -> bool:
|
||||
"""用户是否有「待审核(reviewing)」的提现单。
|
||||
|
||||
只看 reviewing:这类单还没打款,用户确认解绑时会被立即退回现金
|
||||
(refund_reviewing_withdraws_on_unbind),解绑前据此提示用户知情确认。
|
||||
pending 单转账已在途(execute 已过 openid 闸),解绑影响不到、照常打款,故不计入。
|
||||
"""
|
||||
return (
|
||||
db.execute(
|
||||
select(WithdrawOrder.id)
|
||||
.where(
|
||||
WithdrawOrder.user_id == user_id,
|
||||
WithdrawOrder.status == "reviewing",
|
||||
)
|
||||
.limit(1)
|
||||
).first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def unbind_wechat_openid(db: Session, user_id: int) -> None:
|
||||
"""解绑微信:清空 openid。已绑定才清,未绑定为幂等空操作。"""
|
||||
user = db.get(User, user_id)
|
||||
@@ -346,6 +366,33 @@ def unbind_wechat_openid(db: Session, user_id: int) -> None:
|
||||
db.commit()
|
||||
|
||||
|
||||
def refund_reviewing_withdraws_on_unbind(db: Session, user_id: int) -> int:
|
||||
"""解绑微信时,把该用户所有「待审核(reviewing)」提现单立即退回现金并置终态。
|
||||
|
||||
reviewing 单还没打款(钱在 create_withdraw 时已从现金扣进待审核单),解绑后这笔无法
|
||||
打款到微信,直接退回现金最干净:用户即时拿回钱、后台不再挂死单(不必等管理员审核)。
|
||||
复用 _refund_withdraw(退现金 + 流水 + 幂等)。返回退掉的单数。
|
||||
"""
|
||||
orders = (
|
||||
db.execute(
|
||||
select(WithdrawOrder).where(
|
||||
WithdrawOrder.user_id == user_id,
|
||||
WithdrawOrder.status == "reviewing",
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for order in orders:
|
||||
_refund_withdraw(
|
||||
db,
|
||||
order,
|
||||
reason="用户解绑微信,待审核提现自动退回",
|
||||
remark="解绑微信,提现已退回现金余额",
|
||||
)
|
||||
return len(orders)
|
||||
|
||||
|
||||
_OUT_BILL_NO_RE = re.compile(r"^[0-9A-Za-z_-]{8,32}$")
|
||||
|
||||
|
||||
@@ -381,7 +428,8 @@ def _add_cash(db: Session, user_id: int, amount_cents: int) -> int:
|
||||
|
||||
|
||||
def _refund_withdraw(
|
||||
db: Session, order: WithdrawOrder, reason: str, *, final_status: str = "failed"
|
||||
db: Session, order: WithdrawOrder, reason: str, *, final_status: str = "failed",
|
||||
remark: str | None = None,
|
||||
) -> None:
|
||||
"""退回现金 + 写 withdraw_refund 流水 + 单子置终态。幂等:已是退款终态不重复退。
|
||||
|
||||
@@ -389,6 +437,7 @@ def _refund_withdraw(
|
||||
- "failed" (默认):微信转账失败/取消 → 自动退款
|
||||
- "rejected":管理员审核拒绝 → 退款(走 reject_withdraw)
|
||||
两种都已退款,用 in 判定防重复退(并发/对账/拒绝重复点)。
|
||||
remark:给用户可见的现金流水文案;省略则按 final_status 取默认(解绑退回等场景可自定义)。
|
||||
"""
|
||||
if order.status in ("failed", "rejected"):
|
||||
return # 防重复退款(并发/对账与查单/重复拒绝同时触发)
|
||||
@@ -413,7 +462,10 @@ def _refund_withdraw(
|
||||
biz_type="withdraw_refund",
|
||||
ref_id=order.out_bill_no,
|
||||
# 用户可见文案区分"未成功(自动退)"vs"审核未通过";技术原因记在 order.fail_reason
|
||||
remark="提现审核未通过,金额已退回" if final_status == "rejected" else "提现未成功,金额已退回",
|
||||
remark=(
|
||||
remark
|
||||
or ("提现审核未通过,金额已退回" if final_status == "rejected" else "提现未成功,金额已退回")
|
||||
),
|
||||
created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -109,8 +109,16 @@ class BindWechatResultOut(BaseModel):
|
||||
wechat_avatar_url: str | None = None
|
||||
|
||||
|
||||
class UnbindWechatRequest(BaseModel):
|
||||
# 有进行中提现单时,首次解绑会被拦(needs_confirm),用户确认后带 force=true 再调一次才真解绑。
|
||||
force: bool = Field(False, description="跳过「进行中提现」二次确认,强制解绑")
|
||||
|
||||
|
||||
class UnbindWechatResultOut(BaseModel):
|
||||
bound: bool = False
|
||||
# 有进行中提现单且未 force → True:本次未解绑(bound 仍 True),客户端弹确认弹窗。
|
||||
needs_confirm: bool = False
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class WithdrawRequest(BaseModel):
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
|
||||
## 错误码
|
||||
- `404` 未知任务(`unknown task`)
|
||||
- `409` 任务已领取(`task already claimed`)
|
||||
- `409` 任务已领取(`task already claimed`)。一次性任务为已领过;可重复任务(`enable_notification`)为
|
||||
并发/连点撞同一序号、或已「减半到底领满」不再可领。客户端遇 `409` 视为本次无发放(不再加币)。
|
||||
|
||||
## 说明
|
||||
发奖端 gate 由后端把控(如「打开消息提醒」须客户端确认权限已开后才调)。金币已计入余额。
|
||||
可重复任务每次领取金额逐次减半,按 `(user_id, biz_type, ref_id)` 唯一索引去重挡并发重复发放。
|
||||
|
||||
@@ -13,8 +13,13 @@
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `task_key` | string | 任务标识,如 `enable_notification`(打开消息提醒) |
|
||||
| `coin` | int | 完成可得金币 |
|
||||
| `claimed` | bool | 是否已领取 |
|
||||
| `coin` | int | 下一次完成可得金币(可重复任务为减半后的当前档) |
|
||||
| `claimed` | bool | 是否已领取(可重复任务恒 `false`,仅「减半到底领满」后置 `true`) |
|
||||
|
||||
## 说明
|
||||
福利页一次性任务行(如「打开消息提醒」)的状态源。领取走 [tasks-claim](./tasks-claim.md)。
|
||||
福利页任务行的状态源。领取走 [tasks-claim](./tasks-claim.md)。
|
||||
|
||||
`enable_notification`(打开消息提醒)是**可重复领取**任务:每次开启通知发一次,金额逐次减半
|
||||
(750→375→188→…→1),`coin` 返回下一次的金额;客户端按「通知权限是否开启」决定展不展示
|
||||
(开着就隐藏)。`claimed` 平时恒 `false`,减半到底(领满,约 11 次)后置 `true`,客户端据此隐藏。
|
||||
其余为一次性任务,`claimed` 领过即 `true`。
|
||||
|
||||
@@ -5,14 +5,24 @@
|
||||
> 集成实现:见 [integrations/wxpay](../integrations/wxpay.md)。
|
||||
|
||||
## 入参
|
||||
无(用户由 token 确定)。
|
||||
请求体 `UnbindWechatRequest`(**可选**:旧客户端可不带 body,等价 `force=false`):
|
||||
|
||||
| 字段 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `force` | bool | `false` | 跳过「有待审核提现」二次确认,强制解绑。首次解绑传 `false`;命中 `needs_confirm` 后用户确认,再带 `true` 调一次才真解绑 |
|
||||
|
||||
## 出参
|
||||
响应 `200`:`UnbindWechatResultOut`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `bound` | bool | 固定 `false` |
|
||||
| `bound` | bool | 解绑成功为 `false`;命中二次确认(本次未解绑)时为当前真实绑定态(通常 `true`) |
|
||||
| `needs_confirm` | bool | `true` = 有待审核提现且未 `force`,**本次未解绑**,客户端应弹确认弹窗 |
|
||||
| `message` | string \| null | `needs_confirm=true` 时的提示文案,直接展示给用户 |
|
||||
|
||||
## 说明
|
||||
清空当前用户的 `wechat_openid` / `wechat_nickname` / `wechat_avatar_url`。幂等:未绑定时调用也返回 `bound=false`。解绑后该微信可被其他账号绑定。
|
||||
|
||||
**有待审核提现时的二次确认**:用户有 `reviewing`(待审核)状态的提现单且未带 `force=true` 时,**首次解绑被拦下**——不清 openid,返回 `bound`(真实绑定态)+ `needs_confirm=true` + `message`。客户端据此弹确认弹窗,用户确认后带 `force=true` 再调一次即放行解绑。
|
||||
|
||||
> 只拦 `reviewing`:这类单还没打款,用户确认解绑(`force=true`)时**立即退回现金余额**(写 `withdraw_refund` 流水、单子置终态),后台不再挂单、不必等管理员审核。`pending`(打款在途)单转账已发起、解绑影响不到、照常打款到微信,故**不拦**。
|
||||
|
||||
@@ -70,8 +70,7 @@ def test_list_config(admin_client: TestClient, token: str) -> None:
|
||||
items = {i["key"]: i for i in r.json()}
|
||||
assert "signin_rewards" in items and "ad_daily_limit" in items
|
||||
assert items["signin_rewards"]["value"] == [
|
||||
200, 120, 150, 180, 200, 250, 500,
|
||||
200, 250, 300, 350, 400, 500, 3000,
|
||||
200, 200, 300, 200, 400, 400, 800,
|
||||
]
|
||||
assert items["signin_rewards"]["overridden"] is False
|
||||
|
||||
@@ -79,7 +78,7 @@ def test_list_config(admin_client: TestClient, token: str) -> None:
|
||||
def test_update_signin_takes_effect(admin_client: TestClient, token: str) -> None:
|
||||
r = admin_client.patch(
|
||||
"/admin/api/config/signin_rewards",
|
||||
json={"value": [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400]},
|
||||
json={"value": [100, 200, 300, 400, 500, 600, 700]},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
@@ -119,7 +118,7 @@ def test_update_ad_limit_takes_effect(admin_client: TestClient, token: str) -> N
|
||||
|
||||
|
||||
def test_config_validation(admin_client: TestClient, token: str) -> None:
|
||||
# 签到档位长度≠14
|
||||
# 签到档位长度≠7
|
||||
assert admin_client.patch(
|
||||
"/admin/api/config/signin_rewards", json={"value": [1, 2, 3]}, headers=_auth(token)
|
||||
).status_code == 400
|
||||
|
||||
+43
-13
@@ -145,37 +145,67 @@ def test_signin_boost_flow(client) -> None:
|
||||
|
||||
|
||||
def test_task_claim_flow(client) -> None:
|
||||
"""任务列表 → 领"打开消息提醒" → 余额到账 → 重复领 409。"""
|
||||
"""打开消息提醒=可重复任务:每次领取金额减半(750/375/188),claimed 恒 False,余额累加。"""
|
||||
token = _login(client, "13800001003")
|
||||
coin = TASK_REWARDS[TASK_ENABLE_NOTIFICATION]
|
||||
base = TASK_REWARDS[TASK_ENABLE_NOTIFICATION] # 750
|
||||
|
||||
# 1. 任务列表:未领取
|
||||
# 1. 任务列表:可重复任务 claimed 恒 False,coin=首次金额(750)
|
||||
r = client.get("/api/v1/tasks", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
items = {it["task_key"]: it for it in r.json()["items"]}
|
||||
assert items[TASK_ENABLE_NOTIFICATION]["claimed"] is False
|
||||
assert items[TASK_ENABLE_NOTIFICATION]["coin"] == coin
|
||||
assert items[TASK_ENABLE_NOTIFICATION]["coin"] == base # 750
|
||||
|
||||
# 2. 领奖
|
||||
# 2. 第一次领 → 750,余额 750
|
||||
r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["coin_awarded"] == coin
|
||||
assert r.json()["coin_balance"] == coin
|
||||
assert r.json()["coin_awarded"] == 750
|
||||
assert r.json()["coin_balance"] == 750
|
||||
|
||||
# 3. 重复领 → 409
|
||||
r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token))
|
||||
assert r.status_code == 409
|
||||
|
||||
# 4. 列表变为已领取
|
||||
# 3. 列表:下一次金额减半=375,仍 claimed False(可重复)
|
||||
r = client.get("/api/v1/tasks", headers=_auth(token))
|
||||
items = {it["task_key"]: it for it in r.json()["items"]}
|
||||
assert items[TASK_ENABLE_NOTIFICATION]["claimed"] is True
|
||||
assert items[TASK_ENABLE_NOTIFICATION]["claimed"] is False
|
||||
assert items[TASK_ENABLE_NOTIFICATION]["coin"] == 375
|
||||
|
||||
# 4. 第二次领 → 375(余额 1125),第三次 → 188(四舍五入)
|
||||
r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token))
|
||||
assert r.status_code == 200 and r.json()["coin_awarded"] == 375
|
||||
assert r.json()["coin_balance"] == 1125
|
||||
r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token))
|
||||
assert r.status_code == 200 and r.json()["coin_awarded"] == 188
|
||||
|
||||
# 5. 未知任务 → 404
|
||||
r = client.post("/api/v1/tasks/no_such_task/claim", headers=_auth(token))
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_task_notification_claim_capped(client) -> None:
|
||||
"""可重复任务领满(减半到底)后封顶:不再可领 → 409,列表 claimed 置 True。"""
|
||||
from app.core.rewards import notification_max_claims
|
||||
|
||||
token = _login(client, "13800001009")
|
||||
base = TASK_REWARDS[TASK_ENABLE_NOTIFICATION]
|
||||
cap = notification_max_claims(base) # 750 → 11
|
||||
|
||||
# 领满 cap 次,每次都应 200(最后几次发 1 金币)
|
||||
last_coin = None
|
||||
for _ in range(cap):
|
||||
r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
last_coin = r.json()["coin_awarded"]
|
||||
assert last_coin == 1 # 末次是减半到底的 1
|
||||
|
||||
# 再领 → 409(封顶,不再发放)
|
||||
r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token))
|
||||
assert r.status_code == 409, r.text
|
||||
|
||||
# 列表:领满后 claimed 置 True(客户端据此隐藏)
|
||||
r = client.get("/api/v1/tasks", headers=_auth(token))
|
||||
items = {it["task_key"]: it for it in r.json()["items"]}
|
||||
assert items[TASK_ENABLE_NOTIFICATION]["claimed"] is True
|
||||
|
||||
|
||||
def test_exchange_info(client) -> None:
|
||||
token = _login(client, "13800001004")
|
||||
r = client.get("/api/v1/wallet/exchange-info", headers=_auth(token))
|
||||
|
||||
Reference in New Issue
Block a user