fix(welfare): 消息提醒可重复领取并发重复发放修复 + review 清理

代码 review(本分支未提交 WIP)发现并修复:
- 高: enable_notification 可重复领取的发放是无锁读-算-写,并发/连点会算出同一序号
  双倍发钱。给 coin_transaction 加 (user_id,biz_type,ref_id) 部分唯一索引(仅 task_*),
  claim 撞唯一约束→IntegrityError 回滚兜底成 AlreadyClaimedError(409),不双发。
  配套 alembic 迁移 coin_txn_task_ref_uq(挂 head=drop_force_onboarding)。
- 中: 加领取封顶 rewards.notification_max_claims(减半到底=1 后不再可领),挡通知一直关着
  时无限刷 1 金币;列表领满后 claimed 置 True 供客户端隐藏。
- 中: _notification_grant_times 改只数 amount>0 流水,避免日后冲正/负向流水把次数算大。
- 低: 合并 ops_marquee._nickname 两个一字不差的重复分支。
- 文档: tasks-list / tasks-claim 补可重复领取/逐次减半/409 语义。
- 测试: 新增「领满到底→409 + claimed=True」用例;test_welfare 全绿(3 个 proxy 失败
  为 pre-existing httpx mock 问题,与本次无关)。

注: review 标的「do_signin 未归一化老 cycle_day」经验算为非 bug(x%LEN+1 对越界值
本就周期正确,且与 get_status 预览公式恒等),未改。

含本分支签到7天改制/任务文案/膨胀金币等 WIP。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
OuYingJun1024
2026-06-12 23:39:27 +08:00
parent 401c24d15d
commit 32d04d7008
12 changed files with 226 additions and 52 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)
+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
+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`
+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))