32d04d7008
代码 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>
129 lines
4.7 KiB
Python
129 lines
4.7 KiB
Python
"""一次性任务 CRUD:列表状态 + 领奖。
|
|
|
|
领奖原子化:写 user_task(唯一约束防重复)+ 发金币,同一事务 commit。
|
|
任务列表 + 奖励来自 rewards.get_task_rewards(运营后台可改)。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
|
|
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 不在已知任务表里。"""
|
|
|
|
|
|
class AlreadyClaimedError(Exception):
|
|
"""该任务已经领过奖了。"""
|
|
|
|
|
|
@dataclass
|
|
class TaskState:
|
|
task_key: str
|
|
coin: int
|
|
claimed: bool
|
|
|
|
|
|
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())
|
|
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]:
|
|
"""领取一次性任务奖励。返回 (发放金币, 领奖后余额)。
|
|
|
|
未知任务抛 UnknownTaskError;重复领取抛 AlreadyClaimedError。
|
|
"""
|
|
task_rewards = rewards.get_task_rewards(db)
|
|
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
|
|
)
|
|
).scalar_one_or_none()
|
|
if existing is not None:
|
|
raise AlreadyClaimedError
|
|
|
|
coin = task_rewards[task_key]
|
|
db.add(
|
|
UserTask(
|
|
user_id=user_id,
|
|
task_key=task_key,
|
|
status="completed",
|
|
coin_awarded=coin,
|
|
)
|
|
)
|
|
acc, _ = crud_wallet.grant_coins(
|
|
db, user_id, coin, biz_type=f"task_{task_key}", ref_id=task_key
|
|
)
|
|
db.commit()
|
|
return coin, acc.coin_balance
|