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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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