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:
ouzhou
2026-06-13 03:53:43 +08:00
committed by marco
parent 25484aadb8
commit 8d7b91219a
16 changed files with 318 additions and 58 deletions
@@ -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")
+3 -2
View File
@@ -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
View File
@@ -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)
+3 -3
View File
@@ -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
View File
@@ -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 服务端回调发奖)=====
+15
View File
@@ -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(
+9 -9
View File
@@ -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:
+5 -4
View File
@@ -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)
+60 -6
View File
@@ -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
+54 -2
View File
@@ -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),
)
)
+8
View File
@@ -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):
+3 -1
View File
@@ -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)` 唯一索引去重挡并发重复发放。
+8 -3
View File
@@ -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`
+12 -2
View File
@@ -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`(打款在途)单转账已发起、解绑影响不到、照常打款到微信,故**不拦**。
+3 -4
View File
@@ -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
View File
@@ -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))