diff --git a/alembic/versions/coin_transaction_task_ref_unique.py b/alembic/versions/coin_transaction_task_ref_unique.py new file mode 100644 index 0000000..11b8067 --- /dev/null +++ b/alembic/versions/coin_transaction_task_ref_unique.py @@ -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") diff --git a/app/admin/routers/config.py b/app/admin/routers/config.py index 6ab6c41..e581d24 100644 --- a/app/admin/routers/config.py +++ b/app/admin/routers/config.py @@ -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) diff --git a/app/core/config_schema.py b/app/core/config_schema.py index 1546223..65d2a9a 100644 --- a/app/core/config_schema.py +++ b/app/core/config_schema.py @@ -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 不展示也不允许膨胀。", }, } diff --git a/app/core/rewards.py b/app/core/rewards.py index 84de0c7..713282f 100644 --- a/app/core/rewards.py +++ b/app/core/rewards.py @@ -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 服务端回调发奖)===== diff --git a/app/models/wallet.py b/app/models/wallet.py index 8c5b849..bb71b4c 100644 --- a/app/models/wallet.py +++ b/app/models/wallet.py @@ -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( diff --git a/app/repositories/ops_marquee.py b/app/repositories/ops_marquee.py index 688652e..717e449 100644 --- a/app/repositories/ops_marquee.py +++ b/app/repositories/ops_marquee.py @@ -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: diff --git a/app/repositories/signin.py b/app/repositories/signin.py index 3d0927e..c65ce38 100644 --- a/app/repositories/signin.py +++ b/app/repositories/signin.py @@ -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) diff --git a/app/repositories/task.py b/app/repositories/task.py index 9b592b6..309f5e8 100644 --- a/app/repositories/task.py +++ b/app/repositories/task.py @@ -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 diff --git a/docs/api/tasks-claim.md b/docs/api/tasks-claim.md index 64b8a21..5ef1f2c 100644 --- a/docs/api/tasks-claim.md +++ b/docs/api/tasks-claim.md @@ -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)` 唯一索引去重挡并发重复发放。 diff --git a/docs/api/tasks-list.md b/docs/api/tasks-list.md index 63cc73d..d826f1d 100644 --- a/docs/api/tasks-list.md +++ b/docs/api/tasks-list.md @@ -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`。 diff --git a/tests/test_admin_config.py b/tests/test_admin_config.py index 12797bc..4f51fbe 100644 --- a/tests/test_admin_config.py +++ b/tests/test_admin_config.py @@ -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 diff --git a/tests/test_welfare.py b/tests/test_welfare.py index 7904be2..d011907 100644 --- a/tests/test_welfare.py +++ b/tests/test_welfare.py @@ -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))