From 8d7b91219a343a9946357b0f60254984e16848bf Mon Sep 17 00:00:00 2001 From: ouzhou Date: Sat, 13 Jun 2026 03:53:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(wallet):=20=E8=A7=A3=E7=BB=91=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=E5=89=8D=E5=AF=B9=E5=BE=85=E5=AE=A1=E6=A0=B8=E6=8F=90?= =?UTF-8?q?=E7=8E=B0=E4=BA=8C=E6=AC=A1=E7=A1=AE=E8=AE=A4=20(#46)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 改动 解绑微信前若有**待审核(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: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/46 Co-authored-by: ouzhou Co-committed-by: ouzhou --- .../coin_transaction_task_ref_unique.py | 40 +++++++++++ app/admin/routers/config.py | 5 +- app/api/v1/wallet.py | 20 +++++- app/core/config_schema.py | 6 +- app/core/rewards.py | 41 ++++++++++-- app/models/wallet.py | 15 +++++ app/repositories/ops_marquee.py | 18 ++--- app/repositories/signin.py | 9 +-- app/repositories/task.py | 66 +++++++++++++++++-- app/repositories/wallet.py | 56 +++++++++++++++- app/schemas/welfare.py | 8 +++ docs/api/tasks-claim.md | 4 +- docs/api/tasks-list.md | 11 +++- docs/api/wallet-unbind-wechat.md | 14 +++- tests/test_admin_config.py | 7 +- tests/test_welfare.py | 56 ++++++++++++---- 16 files changed, 318 insertions(+), 58 deletions(-) create mode 100644 alembic/versions/coin_transaction_task_ref_unique.py 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/api/v1/wallet.py b/app/api/v1/wallet.py index 8b64c23..55a6f5e 100644 --- a/app/api/v1/wallet.py +++ b/app/api/v1/wallet.py @@ -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) 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/app/repositories/wallet.py b/app/repositories/wallet.py index 7be86b4..3438928 100644 --- a/app/repositories/wallet.py +++ b/app/repositories/wallet.py @@ -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), ) ) diff --git a/app/schemas/welfare.py b/app/schemas/welfare.py index 8977faf..2cb6027 100644 --- a/app/schemas/welfare.py +++ b/app/schemas/welfare.py @@ -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): 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/docs/api/wallet-unbind-wechat.md b/docs/api/wallet-unbind-wechat.md index daba1da..01211dc 100644 --- a/docs/api/wallet-unbind-wechat.md +++ b/docs/api/wallet-unbind-wechat.md @@ -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`(打款在途)单转账已发起、解绑影响不到、照常打款到微信,故**不拦**。 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))