diff --git a/alembic/versions/invite_cash_account_and_compare_reward.py b/alembic/versions/invite_cash_account_and_compare_reward.py new file mode 100644 index 0000000..6be2e7f --- /dev/null +++ b/alembic/versions/invite_cash_account_and_compare_reward.py @@ -0,0 +1,88 @@ +"""invite cash account isolation + compare reward tracking (🅱-1) + +Revision ID: invite_cash_account_and_compare_reward +Revises: feedback_review_fields +Create Date: 2026-06-23 00:00:00.000000 + +邀请功能 v2 账户隔离 + 比价发奖追踪: + - coin_account 加 invite_cash_balance_cents(邀请奖励金独立余额,与金币兑换的 cash 物理隔离) + - withdraw_order 加 source(标记提现扣哪个账户,退款退回对应账户;旧单默认 coin_cash) + - invite_relation 加比价发奖追踪三列(好友比价多次只发一次) + - 新增 invite_cash_transaction 表(邀请奖励金独立流水账本) + +加列均 NOT NULL + server_default,存量行自动填默认值,安全。 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'invite_cash_account_and_compare_reward' +down_revision: Union[str, Sequence[str], None] = 'feedback_review_fields' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 1. 邀请奖励金独立余额(与金币兑换的 cash_balance_cents 物理隔离;红线:两本账不可累加) + op.add_column( + 'coin_account', + sa.Column('invite_cash_balance_cents', sa.Integer(), nullable=False, server_default='0'), + ) + # 2. 提现单标记账户来源:coin_cash / invite_cash,退款退回对应账户(旧单默认 coin_cash) + op.add_column( + 'withdraw_order', + sa.Column('source', sa.String(length=16), nullable=False, server_default='coin_cash'), + ) + # 3. 邀请关系加比价发奖追踪(好友"下载+登录+比价一次"→ 给邀请人发奖,只发一次) + op.add_column( + 'invite_relation', + sa.Column('compare_reward_granted', sa.Boolean(), nullable=False, server_default=sa.false()), + ) + op.add_column( + 'invite_relation', + sa.Column('compare_reward_cents', sa.Integer(), nullable=False, server_default='0'), + ) + op.add_column( + 'invite_relation', + sa.Column('compare_rewarded_at', sa.DateTime(timezone=True), nullable=True), + ) + # 4. 邀请奖励金独立流水表(结构同 cash_transaction;balance_after 记 invite_cash_balance_cents) + op.create_table( + 'invite_cash_transaction', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('amount_cents', sa.Integer(), nullable=False), + sa.Column('balance_after_cents', sa.Integer(), nullable=False), + sa.Column('biz_type', sa.String(length=32), nullable=False), + sa.Column('ref_id', sa.String(length=64), nullable=True), + sa.Column('remark', sa.String(length=128), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id']), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index('ix_invite_cash_transaction_user_id', 'invite_cash_transaction', ['user_id']) + op.create_index('ix_invite_cash_transaction_created_at', 'invite_cash_transaction', ['created_at']) + # 提现退款幂等:一个提现单只退一次(partial unique,对齐 cash_transaction 的 withdraw_refund 去重) + op.create_index( + 'ux_invite_cash_txn_refund_ref', + 'invite_cash_transaction', + ['ref_id'], + unique=True, + sqlite_where=sa.text("biz_type = 'invite_withdraw_refund' AND ref_id IS NOT NULL"), + postgresql_where=sa.text("biz_type = 'invite_withdraw_refund' AND ref_id IS NOT NULL"), + ) + + +def downgrade() -> None: + op.drop_index('ux_invite_cash_txn_refund_ref', table_name='invite_cash_transaction') + op.drop_index('ix_invite_cash_transaction_created_at', table_name='invite_cash_transaction') + op.drop_index('ix_invite_cash_transaction_user_id', table_name='invite_cash_transaction') + op.drop_table('invite_cash_transaction') + op.drop_column('invite_relation', 'compare_rewarded_at') + op.drop_column('invite_relation', 'compare_reward_cents') + op.drop_column('invite_relation', 'compare_reward_granted') + op.drop_column('withdraw_order', 'source') + op.drop_column('coin_account', 'invite_cash_balance_cents') diff --git a/app/api/v1/compare_record.py b/app/api/v1/compare_record.py index b3f52b6..3e4fc7b 100644 --- a/app/api/v1/compare_record.py +++ b/app/api/v1/compare_record.py @@ -21,6 +21,7 @@ from app.api.deps import CurrentUser, DbSession from app.db.session import SessionLocal from app.models.comparison import ComparisonRecord from app.repositories import comparison as crud_compare +from app.repositories import invite as crud_invite from app.services.pricebot_llm_calls import fetch_llm_calls from app.schemas.compare_record import ( CompareStatsOut, @@ -52,6 +53,18 @@ def report_record( # 任务做,不阻塞上报响应(顺带给 pricebot 落盘留足余量)。upsert 已 commit,后台用 # 独立 session 按 record id 回填 llm_calls + 派生 llm_call_count/retry_count。 background_tasks.add_task(_backfill_llm_calls, rec.id, rec.trace_id) + # 邀请 v2 发奖:被邀请人完成一次【成功】比价 → 给邀请人发邀请奖励金(幂等,只发一次)。 + # best-effort:发奖异常不影响比价上报本身(rec 已 commit),只 log;邀请人补偿靠后续对账。 + if rec.status == "success": + try: + reward = crud_invite.try_reward_on_compare(db, user.id) + if reward.status == "granted": + logger.info( + "invite compare reward granted inviter=%s invitee=%s cents=%s", + reward.inviter_user_id, user.id, reward.reward_cents, + ) + except Exception as e: # noqa: BLE001 best-effort,发奖失败不阻塞上报 + logger.warning("invite compare reward failed invitee=%s: %s", user.id, e) logger.info( "compare record user=%s trace=%s biz=%s status=%s saved=%s (llm_calls backfill queued)", user.id, diff --git a/app/api/v1/invite.py b/app/api/v1/invite.py index c5de448..5bbc99e 100644 --- a/app/api/v1/invite.py +++ b/app/api/v1/invite.py @@ -38,7 +38,7 @@ logger = logging.getLogger("shagua.invite") router = APIRouter(prefix="/api/v1/invite", tags=["invite"]) _BIND_MESSAGES = { - "success": "邀请绑定成功,金币已到账", + "success": "邀请绑定成功", "already_bound": "你已绑定过邀请人", "invalid_code": "邀请码无效", "self_invite": "不能填写自己的邀请码", @@ -84,6 +84,8 @@ def _parse_device_model(ua: str) -> str: def my_invite(user: CurrentUser, db: DbSession) -> InviteInfoOut: code = invite_repo.ensure_code(db, user) invited, coins = invite_repo.get_stats(db, user.id) + reward_balance, reward_withdrawn = invite_repo.get_reward_stats(db, user.id) + days_left, is_fresh_round, countdown_text = invite_repo.compute_invite_countdown(user.created_at) sep = "&" if "?" in settings.INVITE_LANDING_URL else "?" share_url = f"{settings.INVITE_LANDING_URL}{sep}ref={code}" return InviteInfoOut( @@ -91,6 +93,11 @@ def my_invite(user: CurrentUser, db: DbSession) -> InviteInfoOut: share_url=share_url, invited_count=invited, coins_earned=coins, + reward_balance_cents=reward_balance, + reward_withdrawn_cents=reward_withdrawn, + countdown_days_left=days_left, + countdown_is_fresh_round=is_fresh_round, + countdown_text=countdown_text, ) @@ -154,7 +161,7 @@ def landing_track( return LandingTrackOut(status="ok") -@router.post("/bind", response_model=BindInviteOut, summary="绑定邀请人(注册即生效,双方发金币)") +@router.post("/bind", response_model=BindInviteOut, summary="绑定邀请人(注册即生效,绑定不发奖)") def bind_invite( req: BindInviteIn, user: CurrentUser, db: DbSession, request: Request ) -> BindInviteOut: diff --git a/app/api/v1/wallet.py b/app/api/v1/wallet.py index 55a6f5e..8641cf2 100644 --- a/app/api/v1/wallet.py +++ b/app/api/v1/wallet.py @@ -200,7 +200,8 @@ def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> Withdraw raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="wechat pay not configured") try: order = crud_wallet.create_withdraw( - db, user.id, req.amount_cents, user_name=req.user_name, out_bill_no=req.out_bill_no + db, user.id, req.amount_cents, source=req.source, + user_name=req.user_name, out_bill_no=req.out_bill_no, ) except crud_wallet.InvalidWithdrawAmountError as e: raise HTTPException( @@ -274,10 +275,11 @@ def withdraw_status( def withdraw_orders( user: CurrentUser, db: DbSession, + source: str | None = Query(None, description="按账户来源过滤:coin_cash / invite_cash;不传=全部"), limit: int = Query(20, ge=1, le=100), cursor: int | None = Query(None, description="上一页末条 id"), ) -> WithdrawOrderPage: - items, next_cursor = crud_wallet.list_withdraw_orders(db, user.id, limit=limit, cursor=cursor) + items, next_cursor = crud_wallet.list_withdraw_orders(db, user.id, source=source, limit=limit, cursor=cursor) return WithdrawOrderPage( items=[WithdrawOrderOut.model_validate(it) for it in items], next_cursor=next_cursor, diff --git a/app/core/rewards.py b/app/core/rewards.py index 6acf242..d931db4 100644 --- a/app/core/rewards.py +++ b/app/core/rewards.py @@ -113,10 +113,9 @@ PRICE_REPORT_REWARD_COINS: int = 1000 FEEDBACK_REWARD_MAX_COINS: int = 10000 -# ===== 邀请好友(注册即生效,邀请人 + 被邀请人各发金币)===== -# 10000 金币 = 1 元,双方各得 1 元。MVP 先用固定常量(不走 app_config)。 -INVITE_INVITER_COINS: int = 10000 -INVITE_INVITEE_COINS: int = 10000 +# ===== 邀请好友(绑定即生效,绑定只建归因关系、双方都不发钱)===== +# v3(冰 2026-06-26):废除 v1 的"绑定双方各发金币"——被邀请人无奖励、邀请人改比价后发现金 +# (见下方 INVITE_COMPARE_REWARD_CENTS)。原 INVITE_INVITER_COINS / INVITE_INVITEE_COINS 已删。 # "新用户闸":被邀请人必须在注册后此窗口内绑定才发奖(挡存量老用户互相填码薅羊毛)。 # 自动绑(剪贴板)在首次注册登录后几秒内发生;留 72h 给手动填码兜底。 INVITE_NEW_USER_WINDOW_HOURS: int = 72 @@ -127,6 +126,13 @@ INVITE_NEW_USER_WINDOW_HOURS: int = 72 INVITE_FP_WINDOW_DAYS: int = 7 +# ===== 邀请好友 v2(好友"下载+登录+比价一次"→ 给邀请人发邀请奖励金·现金)===== +# v2 新规则:不再注册即发金币,改"好友完成首次成功比价"才给【邀请人】发奖,发的是【现金·分】 +# 进独立的邀请奖励金账户(coin_account.invite_cash_balance_cents),与金币体系物理隔离。 +# 200 分 = 2 元。⚠️ 金额待产品定准:邀请主页=2元 / 福利入口=3.5元 不一致,定后改此处。 +INVITE_COMPARE_REWARD_CENTS: int = 200 + + # ===== 看激励视频 / 信息流广告发金币 ===== # eCPM 取自穿山甲 SDK getShowEcpm().getEcpm(),官方口径单位是【分/千次展示】(不是元! # csjplatform 文档原文"通过 getEcpm 获取的单位是分")。计算时先 ÷100 转成元; diff --git a/app/models/__init__.py b/app/models/__init__.py index 09ce54a..05c6c57 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -37,5 +37,6 @@ from app.models.wallet import ( # noqa: F401 CashTransaction, CoinAccount, CoinTransaction, + InviteCashTransaction, WithdrawOrder, ) diff --git a/app/models/invite.py b/app/models/invite.py index 8ed6951..99878f3 100644 --- a/app/models/invite.py +++ b/app/models/invite.py @@ -10,7 +10,7 @@ from __future__ import annotations from datetime import datetime -from sqlalchemy import DateTime, ForeignKey, Integer, String, func +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, false, func from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base @@ -35,6 +35,20 @@ class InviteRelation(Base): inviter_coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0) invitee_coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + # ===== v2 比价发奖追踪(好友"下载+登录+比价一次"→ 给邀请人发邀请奖励金)===== + # 是否已因"好友完成比价"发过奖:好友比价多次只发一次(防重复发,与 invitee 唯一约束双保险) + compare_reward_granted: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default=false() + ) + # 实发给邀请人的邀请奖励金(分);未发为 0 + compare_reward_cents: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, server_default="0" + ) + # 发奖时间(未发为 None) + compare_rewarded_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), index=True, nullable=False ) diff --git a/app/models/wallet.py b/app/models/wallet.py index bb71b4c..cb815d8 100644 --- a/app/models/wallet.py +++ b/app/models/wallet.py @@ -25,6 +25,11 @@ class CoinAccount(Base): ) coin_balance: Mapped[int] = mapped_column(Integer, nullable=False, default=0) cash_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + # 邀请奖励金余额(分)——与金币兑换来的 cash_balance_cents **物理隔离**(产品红线: + # 邀请奖励金 ≠ 看广告/金币现金,两本账不可累加)。好友比价发奖入账、提现出账走它。 + invite_cash_balance_cents: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, server_default="0" + ) # 累计赚取的金币(只增不减),用于"历史总收益"类展示 total_coin_earned: Mapped[int] = mapped_column(Integer, nullable=False, default=0) @@ -108,6 +113,11 @@ class WithdrawOrder(Base): # 商户单号(我们生成,微信查单的 out_bill_no),唯一 out_bill_no: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) amount_cents: Mapped[int] = mapped_column(Integer, nullable=False) + # 这笔提现扣的是哪个账户:coin_cash(金币兑换的现金) / invite_cash(邀请奖励金)。 + # 退款时据此退回**对应**账户,两本账不串。旧单默认 coin_cash。 + source: Mapped[str] = mapped_column( + String(16), nullable=False, default="coin_cash", server_default="coin_cash" + ) # 提现实名(微信达额转账要求):审核后异步打款时要用,发起提现时存下,可空 user_name: Mapped[str | None] = mapped_column(String(64), nullable=True) # 归一化状态:reviewing(待审核) / pending(打款在途) / success / failed(打款失败已退) / rejected(审核拒绝已退) @@ -200,3 +210,41 @@ class CashTransaction(Base): def __repr__(self) -> str: # pragma: no cover return f"" + + +class InviteCashTransaction(Base): + """邀请奖励金流水(单位:分)。与 cash_transaction(金币兑换现金)**物理隔离**—— + 产品红线:邀请奖励金 ≠ 看广告/金币现金,两本账不可累加、各自提现。 + 入账=好友比价发奖(invite_reward),出账=提现(invite_withdraw)/退款(invite_withdraw_refund)。 + 结构与 cash_transaction 同构,balance_after_cents 记的是 coin_account.invite_cash_balance_cents。""" + + __tablename__ = "invite_cash_transaction" + __table_args__ = ( + # 提现退款幂等:一个提现单只退一次(同 cash_transaction 的 withdraw_refund 去重) + Index( + "ux_invite_cash_txn_refund_ref", + "ref_id", + unique=True, + sqlite_where=text("biz_type = 'invite_withdraw_refund' AND ref_id IS NOT NULL"), + postgresql_where=text("biz_type = 'invite_withdraw_refund' AND ref_id IS NOT NULL"), + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("user.id"), index=True, nullable=False + ) + # 正数=入账(发奖),负数=出账(提现) + amount_cents: Mapped[int] = mapped_column(Integer, nullable=False) + balance_after_cents: Mapped[int] = mapped_column(Integer, nullable=False) + # 业务类型:invite_reward / invite_withdraw / invite_withdraw_refund + biz_type: Mapped[str] = mapped_column(String(32), nullable=False) + ref_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + remark: Mapped[str | None] = mapped_column(String(128), nullable=True) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True, nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" diff --git a/app/repositories/invite.py b/app/repositories/invite.py index ca36e31..7fd7dbc 100644 --- a/app/repositories/invite.py +++ b/app/repositories/invite.py @@ -1,13 +1,14 @@ -"""好友邀请 CRUD(注册即生效,邀请人 + 被邀请人各发金币)。 +"""好友邀请 CRUD(注册即生效,绑定只建归因关系、双方都不发钱)。 + +发奖规则(v3,冰 2026-06-26 拍板): + - 绑定时双方都不发钱(被邀请人无奖励、邀请人不发金币)。 + - 邀请人的钱由 try_reward_on_compare 在好友"成功比价一次"后发 2 元邀请奖励金(防刷)。 + - v1 的"绑定双方各发金币"已废;invite_relation 的 inviter_coin/invitee_coin 列保留恒 0(待清)。 防重复发奖三道(仿 ad_reward / 提现的资金安全思路): 1. invitee_user_id 唯一 → 一个被邀请人只能被绑定一次(幂等键)。 2. 自邀屏蔽 → inviter == invitee 直接拒。 3. 现成的手机号唯一(每个被邀请人 = 一个真实手机号账号)= 天然限制刷量规模。 - -发金币复用 wallet.grant_coins(grant 只 flush 不 commit),与建关系记录在**同一事务** -commit,保证"建关系 + 双方加金币"原子。奖励额 = rewards.INVITE_INVITER_COINS / -INVITE_INVITEE_COINS。 """ from __future__ import annotations @@ -34,6 +35,30 @@ def _gen_code() -> str: return "".join(secrets.choice(_CODE_ALPHABET) for _ in range(_CODE_LEN)) +# ===== v2 邀请倒计时(7 天 1 轮,锚点=注册日,自然日差,东八区)===== +# 中国不用夏令时,固定 +8 偏移即可(不依赖 tzdata,Windows 本地联调也稳)。 +_CST = timezone(timedelta(hours=8)) + + +def compute_invite_countdown(register_at: datetime) -> tuple[int, bool, str]: + """按 7 天 1 轮算邀请页倒计时。 + + 锚点 = 用户注册日(user.created_at),按东八区自然日差算 + (跨自然日才减、同日多次登录不减,天然满足)。返回: + (本轮剩余天数 1..7, 是否刚进入新一轮[非首轮第1天], 展示文案)。 + """ + reg = register_at if register_at.tzinfo else register_at.replace(tzinfo=timezone.utc) + days_since = max(0, (datetime.now(_CST).date() - reg.astimezone(_CST).date()).days) + day_in_cycle = days_since % 7 # 0..6(本轮第几天,0-based) + days_left = 7 - day_in_cycle # 7..1(第1天=7、第7天=1) + is_fresh_round = days_since >= 7 and day_in_cycle == 0 # 非首轮的第1天 + if is_fresh_round: + text = "恭喜您进入新一轮邀请!\n距离本轮结束还有7天" + else: + text = f"距本轮结束还有{days_left}天" + return days_left, is_fresh_round, text + + def ensure_code(db: Session, user: User) -> str: """保证 user 有邀请码(懒生成),返回它。唯一约束碰撞则换码重试。 @@ -90,15 +115,17 @@ def _is_new_user(user: User) -> bool: class BindResult: status: str # success / already_bound / invalid_code / self_invite / not_eligible relation: InviteRelation | None = None - invitee_coin: int = 0 # 本次给被邀请人发的金币(success 时 >0) + invitee_coin: int = 0 # v3 起恒 0(绑定不再发金币);保留字段兼容响应 def bind( db: Session, *, invitee: User, invite_code: str, channel: str = "clipboard" ) -> BindResult: - """把 invitee 绑定到 invite_code 对应的邀请人,注册即生效 + 双方发金币。 + """把 invitee 绑定到 invite_code 对应的邀请人,注册即生效。 - 幂等:invitee 已被绑过 → already_bound(不重复发奖)。 + v3 发奖(冰 2026-06-26 拍板):绑定双方都不发钱——被邀请人无奖励,邀请人改"好友成功比价一次 + 才发 2 元邀请奖励金"(见 try_reward_on_compare,防刷)。绑定只建归因关系 + 跑防刷闸。 + 幂等:已绑过 → already_bound。 """ # 幂等:已绑过直接返回(不重复发奖) existing = _relation_of_invitee(db, invitee.id) @@ -114,27 +141,18 @@ def bind( if not _is_new_user(invitee): return BindResult("not_eligible") - inviter_coin = rewards.INVITE_INVITER_COINS - invitee_coin = rewards.INVITE_INVITEE_COINS - + # v3(冰 2026-06-26 拍板):绑定双方都不发钱。被邀请人不再发新人金币(去掉拉新即时激励); + # 邀请人的钱由 try_reward_on_compare 在好友成功比价后发 2 元邀请奖励金(防刷)。 rel = InviteRelation( inviter_user_id=inviter.id, invitee_user_id=invitee.id, channel=(channel or "clipboard")[:16], status="effective", - inviter_coin=inviter_coin, - invitee_coin=invitee_coin, + inviter_coin=0, # v1 金币线已停用,列保留恒 0(待清) + invitee_coin=0, # v3:被邀请人绑定不再发金币 ) db.add(rel) - # 双方发金币(同事务,与建关系一起 commit)。ref_id 互指对方便于对账。 - crud_wallet.grant_coins( - db, inviter.id, inviter_coin, - biz_type="invite_inviter", ref_id=str(invitee.id), remark="邀请好友奖励", - ) - crud_wallet.grant_coins( - db, invitee.id, invitee_coin, - biz_type="invite_invitee", ref_id=str(inviter.id), remark="新人受邀奖励", - ) + # 绑定不发任何金币(邀请人改比价发现金、被邀请人无奖励),仅建归因关系。 try: db.commit() except IntegrityError: @@ -145,16 +163,62 @@ def bind( return BindResult("already_bound", existing) raise except Exception: - # 其它 commit 失败(DB 故障 / PG 序列化冲突等):显式回滚,保证"建关系 + 双方发币" - # 原子(要么全成要么全无),不依赖 get_db 关闭时的隐式回滚,语义更硬。 + # 其它 commit 失败(DB 故障 / PG 序列化冲突等):显式回滚,保证建关系原子, + # 不依赖 get_db 关闭时的隐式回滚,语义更硬。 db.rollback() raise db.refresh(rel) - return BindResult("success", rel, invitee_coin) + return BindResult("success", rel) + + +@dataclass +class CompareRewardResult: + status: str # granted / no_relation / already_granted / inviter_inactive + inviter_user_id: int | None = None + reward_cents: int = 0 + + +def try_reward_on_compare(db: Session, invitee_user_id: int) -> CompareRewardResult: + """被邀请人完成一次成功比价时调用:若其有邀请关系且尚未发过比价奖,给【邀请人】发邀请奖励金。 + + v2 发奖规则核心(替代 v1 的"注册即发金币"):好友"下载+登录+比价一次"→ 邀请人得 2 元现金。 + 幂等:compare_reward_granted 标记保证好友比价多次只发一次。无邀请关系 / 已发过 / 邀请人失效 + → 空操作。发奖(grant_invite_cash 入账独立账户)+ 置标记同事务 commit,保证原子。 + """ + rel = _relation_of_invitee(db, invitee_user_id) + if rel is None: + return CompareRewardResult("no_relation") + if rel.compare_reward_granted: + return CompareRewardResult("already_granted", rel.inviter_user_id) + + inviter = db.get(User, rel.inviter_user_id) + if inviter is None or inviter.status != "active": + # 邀请人注销 / 封禁:本次不发、不置标记,待其恢复后下次比价再试(保守,不吞奖励) + return CompareRewardResult("inviter_inactive", rel.inviter_user_id) + + reward = rewards.INVITE_COMPARE_REWARD_CENTS + rel.compare_reward_granted = True + rel.compare_reward_cents = reward + rel.compare_rewarded_at = datetime.now(timezone.utc) + # 发邀请奖励金到邀请人的独立账户(与金币隔离),ref_id 指向被邀请人便于对账 + crud_wallet.grant_invite_cash( + db, inviter.id, reward, + biz_type="invite_reward", ref_id=str(invitee_user_id), remark="好友比价奖励", + ) + try: + db.commit() + except Exception: + db.rollback() + raise + return CompareRewardResult("granted", inviter.id, reward) def get_stats(db: Session, inviter_id: int) -> tuple[int, int]: - """返回 (已成功邀请人数, 累计从邀请获得的金币)。""" + """返回 (已成功邀请人数, 累计从邀请获得的金币)。 + + 金币口径(inviter_coin 之和)自 v3 起恒 0(邀请人收益改走邀请奖励金,见 get_reward_stats / + try_reward_on_compare);保留返回位兼容旧响应字段 coins_earned。 + """ count = db.execute( select(func.count()) .select_from(InviteRelation) @@ -167,6 +231,26 @@ def get_stats(db: Session, inviter_id: int) -> tuple[int, int]: return int(count), int(coins) +def get_reward_stats(db: Session, inviter_id: int) -> tuple[int, int]: + """v2 邀请奖励金战绩:(可提现余额/分, 累计提现成功/分)。 + + 余额 = coin_account.invite_cash_balance_cents;累计提现 = 该用户 source=invite_cash 且 + status=success 的提现单金额之和(成功打款才算)。供 /invite/me 提现板块展示。 + """ + from app.models.wallet import CoinAccount, WithdrawOrder + balance = db.execute( + select(CoinAccount.invite_cash_balance_cents).where(CoinAccount.user_id == inviter_id) + ).scalar_one_or_none() + withdrawn = db.execute( + select(func.coalesce(func.sum(WithdrawOrder.amount_cents), 0)).where( + WithdrawOrder.user_id == inviter_id, + WithdrawOrder.source == "invite_cash", + WithdrawOrder.status == "success", + ) + ).scalar_one() + return int(balance or 0), int(withdrawn) + + def _mask_phone(phone: str) -> str: """手机号脱敏:138****8888。前端拿不到完整号,展示被邀请人时在此兜底名字。 @@ -210,7 +294,7 @@ def get_invitees( items.append({ "display_name": u.nickname or u.wechat_nickname or _mask_phone(u.phone), "avatar_url": u.avatar_url or u.wechat_avatar_url or None, - "coins": rel.inviter_coin, + "coins": rel.inviter_coin, # v3 起恒 0(邀请人收益改走邀请奖励金) "invited_at": rel.created_at, }) has_more = offset + len(rows) < int(total) diff --git a/app/repositories/wallet.py b/app/repositories/wallet.py index a38ac88..611bbc9 100644 --- a/app/repositories/wallet.py +++ b/app/repositories/wallet.py @@ -24,6 +24,7 @@ from app.models.wallet import ( CashTransaction, CoinAccount, CoinTransaction, + InviteCashTransaction, WechatTransferAuthorization, WithdrawOrder, ) @@ -166,6 +167,36 @@ def grant_cash( return acc, txn +def grant_invite_cash( + db: Session, + user_id: int, + amount_cents: int, + *, + biz_type: str, + ref_id: str | None = None, + remark: str | None = None, +) -> tuple[CoinAccount, InviteCashTransaction]: + """邀请奖励金变动入口(正数入账 / 负数出账)。更新 invite_cash_balance_cents + 写 + invite_cash_transaction,不 commit。与金币兑换的 cash_balance_cents **物理隔离** + (产品红线:邀请奖励金 ≠ 金币现金,两本账不可累加)。返回 (account, transaction), + 调用方负责 commit。不在此校验扣成负——由调用方按业务保护。""" + acc = get_or_create_account(db, user_id, commit=False) + acc.invite_cash_balance_cents += amount_cents + + txn = InviteCashTransaction( + user_id=user_id, + amount_cents=amount_cents, + balance_after_cents=acc.invite_cash_balance_cents, + biz_type=biz_type, + ref_id=ref_id, + remark=remark, + created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None), + ) + db.add(txn) + db.flush() + return acc, txn + + def list_coin_transactions( db: Session, user_id: int, @@ -411,33 +442,43 @@ def refund_reviewing_withdraws_on_unbind(db: Session, user_id: int) -> int: _OUT_BILL_NO_RE = re.compile(r"^[0-9A-Za-z_-]{8,32}$") -def _try_deduct_cash(db: Session, user_id: int, amount_cents: int) -> bool: - """原子扣减现金:仅当余额足够时扣,返回是否成功。 +def _balance_col(source: str): + """提现账户来源 → CoinAccount 余额列。invite_cash=邀请奖励金;否则金币兑换的现金。 + 两账户物理隔离,提现扣款/退款都按 source 走对应列,互不串。""" + return ( + CoinAccount.invite_cash_balance_cents + if source == "invite_cash" + else CoinAccount.cash_balance_cents + ) - 用带条件的 UPDATE(`WHERE cash_balance_cents >= amount`)避免"读-判断-写"竞态—— - 并发/重试时不会两次都通过余额检查导致超额扣款(SQLite 串行写、Postgres 行级,均安全)。 + +def _try_deduct_cash(db: Session, user_id: int, amount_cents: int, source: str = "coin_cash") -> bool: + """原子扣减指定账户余额:仅当余额足够时扣,返回是否成功。 + + 用带条件的 UPDATE(`WHERE >= amount`)避免"读-判断-写"竞态——并发/重试时不会两次 + 都通过余额检查导致超额扣款(SQLite 串行写、Postgres 行级,均安全)。source 决定扣 + cash_balance_cents(coin_cash) 还是 invite_cash_balance_cents(invite_cash)。 """ + col = _balance_col(source) res = db.execute( update(CoinAccount) - .where( - CoinAccount.user_id == user_id, - CoinAccount.cash_balance_cents >= amount_cents, - ) - .values(cash_balance_cents=CoinAccount.cash_balance_cents - amount_cents) + .where(CoinAccount.user_id == user_id, col >= amount_cents) + .values({col: col - amount_cents}) ) return res.rowcount == 1 -def _add_cash(db: Session, user_id: int, amount_cents: int) -> int: - """原子增加现金(退款用),返回加后余额。""" +def _add_cash(db: Session, user_id: int, amount_cents: int, source: str = "coin_cash") -> int: + """原子增加指定账户余额(退款用),返回加后余额。source 决定退回哪个账户(两账户隔离)。""" + col = _balance_col(source) db.execute( update(CoinAccount) .where(CoinAccount.user_id == user_id) - .values(cash_balance_cents=CoinAccount.cash_balance_cents + amount_cents) + .values({col: col + amount_cents}) ) db.flush() bal = db.execute( - select(CoinAccount.cash_balance_cents).where(CoinAccount.user_id == user_id) + select(col).where(CoinAccount.user_id == user_id) ).scalar_one() return bal @@ -456,11 +497,15 @@ def _refund_withdraw( """ if order.status in ("failed", "rejected"): return # 防重复退款(并发/对账与查单/重复拒绝同时触发) + # 账户隔离:按 order.source 退回对应账户 + 写对应退款流水表 + is_invite = order.source == "invite_cash" + txn_model = InviteCashTransaction if is_invite else CashTransaction + refund_biz = "invite_withdraw_refund" if is_invite else "withdraw_refund" refunded_txn_id = db.execute( - select(CashTransaction.id).where( - CashTransaction.user_id == order.user_id, - CashTransaction.biz_type == "withdraw_refund", - CashTransaction.ref_id == order.out_bill_no, + select(txn_model.id).where( + txn_model.user_id == order.user_id, + txn_model.biz_type == refund_biz, + txn_model.ref_id == order.out_bill_no, ).limit(1) ).scalar_one_or_none() if refunded_txn_id is not None: @@ -468,13 +513,13 @@ def _refund_withdraw( order.fail_reason = reason[:256] db.commit() return - bal = _add_cash(db, order.user_id, order.amount_cents) + bal = _add_cash(db, order.user_id, order.amount_cents, order.source) db.add( - CashTransaction( + txn_model( user_id=order.user_id, amount_cents=order.amount_cents, balance_after_cents=bal, - biz_type="withdraw_refund", + biz_type=refund_biz, ref_id=order.out_bill_no, # 用户可见文案区分"未成功(自动退)"vs"审核未通过";技术原因记在 order.fail_reason remark=( @@ -492,13 +537,13 @@ def _refund_withdraw( db.commit() except IntegrityError: # 并发退款兜底:唯一退款流水已被另一事务写入时,回滚本事务的加钱和流水, - # 再把订单状态补到终态。这样无论拒绝/查单/对账怎么并发,现金最多退一次。 + # 再把订单状态补到终态。这样无论拒绝/查单/对账怎么并发,金额最多退一次。 db.rollback() refunded_txn_id = db.execute( - select(CashTransaction.id).where( - CashTransaction.user_id == user_id, - CashTransaction.biz_type == "withdraw_refund", - CashTransaction.ref_id == out_bill_no, + select(txn_model.id).where( + txn_model.user_id == user_id, + txn_model.biz_type == refund_biz, + txn_model.ref_id == out_bill_no, ).limit(1) ).scalar_one_or_none() if refunded_txn_id is None: @@ -562,6 +607,7 @@ def create_withdraw( user_id: int, amount_cents: int, *, + source: str = "coin_cash", user_name: str | None = None, out_bill_no: str | None = None, ) -> WithdrawOrder: @@ -608,20 +654,23 @@ def create_withdraw( # 账户须存在(原子扣款的 UPDATE 不会建账户) get_or_create_account(db, user_id, commit=True) - # #1 原子扣款:余额不足时影响行数为 0 - if not _try_deduct_cash(db, user_id, amount_cents): + # #1 原子扣款:余额不足时影响行数为 0(按 source 扣对应账户) + if not _try_deduct_cash(db, user_id, amount_cents, source): db.rollback() raise InsufficientCashError + is_invite = source == "invite_cash" + txn_model = InviteCashTransaction if is_invite else CashTransaction + withdraw_biz = "invite_withdraw" if is_invite else "withdraw" bal = db.execute( - select(CoinAccount.cash_balance_cents).where(CoinAccount.user_id == user_id) + select(_balance_col(source)).where(CoinAccount.user_id == user_id) ).scalar_one() db.add( - CashTransaction( + txn_model( user_id=user_id, amount_cents=-amount_cents, balance_after_cents=bal, - biz_type="withdraw", + biz_type=withdraw_biz, ref_id=out_bill_no, remark="提现到微信零钱(待审核)", created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None), @@ -631,6 +680,7 @@ def create_withdraw( user_id=user_id, out_bill_no=out_bill_no, amount_cents=amount_cents, + source=source, user_name=user_name, status="reviewing", ) @@ -1007,10 +1057,12 @@ def reconcile_pending_withdraws(db: Session, *, older_than_minutes: int = 15) -> def list_withdraw_orders( - db: Session, user_id: int, *, limit: int = 20, cursor: int | None = None + db: Session, user_id: int, *, source: str | None = None, limit: int = 20, cursor: int | None = None ) -> tuple[list[WithdrawOrder], int | None]: - """提现单分页(按 id 倒序,游标式)。""" + """提现单分页(按 id 倒序,游标式)。source 非空时只返回该账户来源的单(coin_cash / invite_cash)。""" stmt = select(WithdrawOrder).where(WithdrawOrder.user_id == user_id) + if source is not None: + stmt = stmt.where(WithdrawOrder.source == source) if cursor is not None: stmt = stmt.where(WithdrawOrder.id < cursor) stmt = stmt.order_by(WithdrawOrder.id.desc()).limit(limit) diff --git a/app/schemas/invite.py b/app/schemas/invite.py index 0564595..24e56b4 100644 --- a/app/schemas/invite.py +++ b/app/schemas/invite.py @@ -10,7 +10,12 @@ class InviteInfoOut(BaseModel): invite_code: str # 我的邀请码 share_url: str # 落地页链接(含 ?ref=),前端据此生成二维码 + 复制分享 invited_count: int # 已成功邀请人数 - coins_earned: int # 累计从邀请获得的金币 + coins_earned: int # 累计从邀请获得的金币(v1 口径;v2 邀请人改发奖励金) + reward_balance_cents: int = 0 # v2 可提现邀请奖励金(分) + reward_withdrawn_cents: int = 0 # v2 累计提现成功的邀请奖励金(分) + countdown_days_left: int = 7 # v2 本轮剩余天数(7 天 1 轮) + countdown_is_fresh_round: bool = False # 是否刚进入新一轮(非首轮第1天) + countdown_text: str = "" # 倒计时展示文案(前端直接显示,新轮含换行) class LandingTrackIn(BaseModel): diff --git a/app/schemas/welfare.py b/app/schemas/welfare.py index 2cb6027..df13be3 100644 --- a/app/schemas/welfare.py +++ b/app/schemas/welfare.py @@ -16,6 +16,7 @@ class CoinAccountOut(BaseModel): coin_balance: int = Field(..., description="当前金币余额") cash_balance_cents: int = Field(..., description="当前现金余额(分)") + invite_cash_balance_cents: int = Field(0, description="邀请奖励金余额(分,与现金隔离)") total_coin_earned: int = Field(..., description="累计赚取金币") @@ -123,6 +124,7 @@ class UnbindWechatResultOut(BaseModel): class WithdrawRequest(BaseModel): amount_cents: int = Field(..., gt=0, description="提现金额(分)") + source: str = Field("coin_cash", description="提现账户:coin_cash(金币现金) / invite_cash(邀请奖励金)") user_name: str | None = Field(None, description="实名(达额时微信要求,可空)") out_bill_no: str | None = Field( None, description="客户端幂等键(商户单号):同号重试不重复转账。不传则服务端生成" @@ -166,6 +168,7 @@ class WithdrawOrderOut(BaseModel): id: int out_bill_no: str amount_cents: int + source: str = Field("coin_cash", description="提现账户:coin_cash / invite_cash") status: str = Field(..., description="reviewing(待审核) / pending / success / failed / rejected") wechat_state: str | None = None fail_reason: str | None = None diff --git a/data/media/dl.html b/data/media/dl.html index cf1519a..f580014 100644 --- a/data/media/dl.html +++ b/data/media/dl.html @@ -8,122 +8,231 @@ - -

傻瓜比价

-
买什么都先比一比
自动帮你找全网最低价
- -
-
🍔 点外卖前一键比价,美团/京东/淘宝到手价一目了然
-
🎟️ 自动领遍各平台红包券,能省的一分不漏
-
💰 省下的钱看得见,还能赚金币提现
-
- - -
Android 安卓版 · 应用商店安全下载
- -
- 将前往应用商店下载,安全放心。
- 本页为内部测试页。 -
- - -
- - - - -
-
点击右上角 ···
选择「在浏览器打开
-
微信里无法直接下载安装包
需在系统浏览器中完成下载
-
-
1点右上角的 ··· 菜单
-
2选择「在浏览器打开」
-
3在浏览器里按提示去应用商店下载
+
+
+
+
+ +

傻瓜比价

+
+

跨平台比价,用傻瓜

+
+ +
-
我知道了 ✕
+
+ + + 优惠券轻松领 + 羊毛全都不错过 + +
+
+ + + 一键全网找底价 + 再也不用费力切屏 + +
+
+ + + +
+
+ diff --git a/tests/test_invite.py b/tests/test_invite.py index a578c6a..994ce8b 100644 --- a/tests/test_invite.py +++ b/tests/test_invite.py @@ -1,4 +1,4 @@ -"""好友邀请测试:邀请码、绑定、双方发金币、幂等、自邀/无效码屏蔽、指纹兜底归因。 +"""好友邀请测试:邀请码、绑定(双方不发钱)、幂等、自邀/无效码屏蔽、指纹兜底归因。 用 sms mock 登录拿 token(同 test_welfare),再跑邀请闭环。 """ @@ -10,8 +10,6 @@ from sqlalchemy import select from app.core.rewards import ( INVITE_FP_WINDOW_DAYS, - INVITE_INVITEE_COINS, - INVITE_INVITER_COINS, INVITE_NEW_USER_WINDOW_HOURS, ) from app.db.session import SessionLocal @@ -56,8 +54,8 @@ def test_invite_me_returns_stable_code(client) -> None: assert _my_code(client, token) == body["invite_code"] -def test_bind_flow_both_get_coins(client) -> None: - """B 用 A 的码绑定 → 双方各得 1 万金币;A 战绩 +1。""" +def test_bind_flow_no_coins_either_side(client) -> None: + """v3:B 用 A 的码绑定 → 双方都不发钱(被邀请人无奖励、邀请人改比价后发现金)。""" a = _login(client, "13800002002") b = _login(client, "13800002003") a_code = _my_code(client, a) @@ -69,16 +67,16 @@ def test_bind_flow_both_get_coins(client) -> None: assert r.status_code == 200, r.text res = r.json() assert res["status"] == "success" - assert res["coins_awarded"] == INVITE_INVITEE_COINS + assert res["coins_awarded"] == 0 - # 双方金币到账 - assert _coin_balance(client, b) == INVITE_INVITEE_COINS - assert _coin_balance(client, a) == INVITE_INVITER_COINS + # 绑定后双方金币都不增(邀请人收益改走"好友比价发 2 元邀请奖励金") + assert _coin_balance(client, b) == 0 + assert _coin_balance(client, a) == 0 - # A 的战绩:已邀 1 人,累计获得 = 邀请人那份 + # A 的战绩:已邀 1 人;金币口径收益恒 0(邀请人收益走邀请奖励金,见 try_reward_on_compare) r = client.get("/api/v1/invite/me", headers=_auth(a)) assert r.json()["invited_count"] == 1 - assert r.json()["coins_earned"] == INVITE_INVITER_COINS + assert r.json()["coins_earned"] == 0 def test_bind_idempotent_no_double_reward(client) -> None: @@ -217,9 +215,9 @@ def test_bind_by_fingerprint_success(client) -> None: ) assert r2.status_code == 200, r2.text assert r2.json()["status"] == "success" - # 双方各发金币 - assert _coin_balance(client, a) == INVITE_INVITER_COINS - assert _coin_balance(client, b) == INVITE_INVITEE_COINS + # v3:绑定双方都不发钱(被邀请人无奖励、邀请人改比价发现金) + assert _coin_balance(client, a) == 0 + assert _coin_balance(client, b) == 0 def test_bind_by_fingerprint_not_found(client) -> None: @@ -322,7 +320,8 @@ def test_invitees_basic(client) -> None: names = {it["display_name"] for it in body["items"]} assert names == expected_names assert all(it["avatar_url"] is None for it in body["items"]) - assert all(it["coins"] == INVITE_INVITER_COINS for it in body["items"]) + # v2:绑定时邀请人那份为 0(收益改"好友比价才发奖"),故每条 coins 字段=0 + assert all(it["coins"] == 0 for it in body["items"]) def test_invitees_order_desc(client) -> None: diff --git a/tests/test_invite_cash_withdraw.py b/tests/test_invite_cash_withdraw.py new file mode 100644 index 0000000..26a2d7d --- /dev/null +++ b/tests/test_invite_cash_withdraw.py @@ -0,0 +1,193 @@ +"""邀请奖励金提现账户隔离测试:提现扣 invite_cash 账户、退款退回 invite_cash、与金币现金不串、 +/invite/me 返回奖励金战绩、提现单 source 过滤。复用 test_withdraw 的 wxpay monkeypatch 模式。 +""" +from __future__ import annotations + +from sqlalchemy import select + +from app.db.session import SessionLocal +from app.models.user import User +from app.models.wallet import CoinAccount, InviteCashTransaction +from app.repositories import wallet as crud_wallet + + +def _login(client, phone: str) -> str: + client.post("/api/v1/auth/sms/send", json={"phone": phone}) + r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"}) + assert r.status_code == 200, r.text + return r.json()["access_token"] + + +def _auth(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def _patch_userinfo(monkeypatch, openid: str) -> None: + monkeypatch.setattr( + "app.integrations.wxpay.code_to_userinfo", + lambda code: {"openid": openid, "nickname": None, "avatar_url": None, "raw": {}}, + ) + + +def _seed_balances(client, token: str, phone: str, *, cash: int = 0, invite_cash: int = 0) -> None: + """访问 /account 触发建账户,再 DB 直接灌两个账户余额(没有"加钱"接口,正常靠兑换/发奖)。""" + client.get("/api/v1/wallet/account", headers=_auth(token)) + db = SessionLocal() + try: + user = db.execute(select(User).where(User.phone == phone)).scalar_one() + acc = db.get(CoinAccount, user.id) + acc.cash_balance_cents = cash + acc.invite_cash_balance_cents = invite_cash + db.commit() + finally: + db.close() + + +def _balances(client, token: str) -> tuple[int, int]: + j = client.get("/api/v1/wallet/account", headers=_auth(token)).json() + return j["cash_balance_cents"], j["invite_cash_balance_cents"] + + +def _reject(bill: str, reason: str = "测试拒绝") -> None: + db = SessionLocal() + try: + crud_wallet.reject_withdraw(db, bill, reason) + finally: + db.close() + + +def test_invite_cash_withdraw_deducts_invite_account(client, monkeypatch) -> None: + """source=invite_cash 提现 → 扣 invite_cash_balance_cents,不动 cash;流水落 invite_cash_transaction。""" + _patch_userinfo(monkeypatch, "openid_ic_1") + token = _login(client, "13800004001") + _seed_balances(client, token, "13800004001", cash=300, invite_cash=500) + client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token)) + + r = client.post( + "/api/v1/wallet/withdraw", + json={"amount_cents": 200, "source": "invite_cash"}, + headers=_auth(token), + ) + assert r.status_code == 200, r.text + assert r.json()["status"] == "reviewing" + + cash, invite_cash = _balances(client, token) + assert invite_cash == 300 # 扣了邀请奖励金 + assert cash == 300 # 金币现金没动 + + db = SessionLocal() + try: + user = db.execute(select(User).where(User.phone == "13800004001")).scalar_one() + txns = db.execute( + select(InviteCashTransaction).where( + InviteCashTransaction.user_id == user.id, + InviteCashTransaction.biz_type == "invite_withdraw", + ) + ).scalars().all() + assert len(txns) == 1 and txns[0].amount_cents == -200 + finally: + db.close() + orders = client.get("/api/v1/wallet/withdraw-orders", headers=_auth(token)).json()["items"] + assert orders[0]["source"] == "invite_cash" + + +def test_invite_cash_reject_refunds_invite_account(client, monkeypatch) -> None: + """拒绝 invite_cash 提现 → 退回 invite_cash,不串金币现金;退款流水落 invite_cash_transaction。""" + _patch_userinfo(monkeypatch, "openid_ic_2") + token = _login(client, "13800004002") + _seed_balances(client, token, "13800004002", cash=0, invite_cash=500) + client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token)) + + r = client.post( + "/api/v1/wallet/withdraw", + json={"amount_cents": 200, "source": "invite_cash"}, + headers=_auth(token), + ) + bill = r.json()["out_bill_no"] + cash, invite_cash = _balances(client, token) + assert invite_cash == 300 and cash == 0 # 扣后 + + _reject(bill) + + cash, invite_cash = _balances(client, token) + assert invite_cash == 500 # 退回邀请奖励金 + assert cash == 0 # 没串进现金 + + db = SessionLocal() + try: + user = db.execute(select(User).where(User.phone == "13800004002")).scalar_one() + refunds = db.execute( + select(InviteCashTransaction).where( + InviteCashTransaction.user_id == user.id, + InviteCashTransaction.biz_type == "invite_withdraw_refund", + ) + ).scalars().all() + assert len(refunds) == 1 and refunds[0].amount_cents == 200 + finally: + db.close() + + +def test_two_accounts_withdraw_independent(client, monkeypatch) -> None: + """两账户各提各的不串:先提 invite_cash(拒绝结清),再提 cash,各扣各账户。 + 注:一个用户同一时间只能一个活跃提现单(跨账户),故第二笔需先结清第一笔。""" + _patch_userinfo(monkeypatch, "openid_ic_3") + token = _login(client, "13800004003") + _seed_balances(client, token, "13800004003", cash=400, invite_cash=500) + client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token)) + + r1 = client.post( + "/api/v1/wallet/withdraw", + json={"amount_cents": 200, "source": "invite_cash"}, + headers=_auth(token), + ) + _reject(r1.json()["out_bill_no"]) # 退回 invite_cash + 结清活跃单 + r2 = client.post( + "/api/v1/wallet/withdraw", + json={"amount_cents": 100, "source": "coin_cash"}, + headers=_auth(token), + ) + assert r2.json()["status"] == "reviewing" + + cash, invite_cash = _balances(client, token) + assert invite_cash == 500 # 已退回 + assert cash == 300 # 扣了 cash 100 + + +def test_invite_me_returns_reward_stats(client) -> None: + """/invite/me 返回 reward_balance_cents(可提现奖励金)。""" + token = _login(client, "13800004004") + _seed_balances(client, token, "13800004004", invite_cash=350) + j = client.get("/api/v1/invite/me", headers=_auth(token)).json() + assert j["reward_balance_cents"] == 350 + assert j["reward_withdrawn_cents"] == 0 + + +def test_withdraw_orders_source_filter(client, monkeypatch) -> None: + """/wallet/withdraw-orders?source=invite_cash 只返回邀请奖励金提现单。""" + _patch_userinfo(monkeypatch, "openid_ic_5") + token = _login(client, "13800004005") + _seed_balances(client, token, "13800004005", cash=400, invite_cash=500) + client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token)) + + r1 = client.post( + "/api/v1/wallet/withdraw", + json={"amount_cents": 200, "source": "invite_cash"}, + headers=_auth(token), + ) + _reject(r1.json()["out_bill_no"]) # 结清,才能提第二笔 + client.post( + "/api/v1/wallet/withdraw", + json={"amount_cents": 100, "source": "coin_cash"}, + headers=_auth(token), + ) + + all_orders = client.get("/api/v1/wallet/withdraw-orders", headers=_auth(token)).json()["items"] + invite_orders = client.get( + "/api/v1/wallet/withdraw-orders", params={"source": "invite_cash"}, headers=_auth(token) + ).json()["items"] + coin_orders = client.get( + "/api/v1/wallet/withdraw-orders", params={"source": "coin_cash"}, headers=_auth(token) + ).json()["items"] + assert len(all_orders) == 2 + assert len(invite_orders) == 1 and invite_orders[0]["source"] == "invite_cash" + assert len(coin_orders) == 1 and coin_orders[0]["source"] == "coin_cash" diff --git a/tests/test_invite_compare_reward.py b/tests/test_invite_compare_reward.py new file mode 100644 index 0000000..3761f3a --- /dev/null +++ b/tests/test_invite_compare_reward.py @@ -0,0 +1,114 @@ +"""邀请 v3 比价发奖测试:好友完成成功比价 → 邀请人得邀请奖励金(独立账户),幂等只发一次, +账户隔离不串金币/现金。被邀请人绑定不得任何奖励由 test_invite.py 覆盖。 +""" +from __future__ import annotations + +from sqlalchemy import select + +from app.core.rewards import INVITE_COMPARE_REWARD_CENTS +from app.db.session import SessionLocal +from app.models.wallet import CoinAccount, InviteCashTransaction +from app.repositories.user import get_user_by_phone + + +def _login(client, phone: str) -> str: + client.post("/api/v1/auth/sms/send", json={"phone": phone}) + r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"}) + assert r.status_code == 200, r.text + return r.json()["access_token"] + + +def _auth(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def _my_code(client, token: str) -> str: + return client.get("/api/v1/invite/me", headers=_auth(token)).json()["invite_code"] + + +def _bind(client, token: str, code: str): + return client.post("/api/v1/invite/bind", json={"invite_code": code}, headers=_auth(token)) + + +def _report_compare(client, token: str, trace_id: str, status: str = "success"): + return client.post( + "/api/v1/compare/record", + json={"trace_id": trace_id, "status": status}, + headers=_auth(token), + ) + + +def _invite_cash(phone: str) -> int: + """被试用户的邀请奖励金余额(分)。专用查询端点见小步3,这里直接查 DB。""" + with SessionLocal() as db: + u = get_user_by_phone(db, phone) + acc = db.get(CoinAccount, u.id) if u else None + return acc.invite_cash_balance_cents if acc else 0 + + +def test_compare_reward_granted_to_inviter(client) -> None: + """B 被 A 邀请后完成一次成功比价 → A 得邀请奖励金 2 元(独立账户),B 不得该奖。""" + a = _login(client, "13800003001") + b = _login(client, "13800003002") + _bind(client, b, _my_code(client, a)) + + assert _invite_cash("13800003001") == 0 # 发奖前 + + r = _report_compare(client, b, "trace-reward-1") + assert r.status_code == 200, r.text + + assert _invite_cash("13800003001") == INVITE_COMPARE_REWARD_CENTS # 邀请人到账 + assert _invite_cash("13800003002") == 0 # 被邀请人不得此奖 + + +def test_compare_reward_idempotent(client) -> None: + """好友比价多次 → 只发一次(compare_reward_granted 幂等)。""" + a = _login(client, "13800003003") + b = _login(client, "13800003004") + _bind(client, b, _my_code(client, a)) + + _report_compare(client, b, "trace-idem-1") + _report_compare(client, b, "trace-idem-2") # 第二次比价(不同 trace) + _report_compare(client, b, "trace-idem-1") # 重复上报同 trace + + assert _invite_cash("13800003003") == INVITE_COMPARE_REWARD_CENTS # 仍只发一次 + + +def test_compare_no_relation_no_reward(client) -> None: + """没有邀请关系的人比价 → 不发奖(没人是他的邀请人)。""" + x = _login(client, "13800003005") + r = _report_compare(client, x, "trace-norel-1") + assert r.status_code == 200, r.text + assert _invite_cash("13800003005") == 0 + + +def test_compare_failed_no_reward(client) -> None: + """失败的比价(status=failed)不触发发奖。""" + a = _login(client, "13800003006") + b = _login(client, "13800003007") + _bind(client, b, _my_code(client, a)) + + _report_compare(client, b, "trace-fail-1", status="failed") + assert _invite_cash("13800003006") == 0 + + +def test_compare_reward_isolated_from_coin_cash(client) -> None: + """账户隔离:邀请奖励金进 invite_cash,不串 cash_balance_cents;流水落 invite_cash_transaction。""" + a = _login(client, "13800003008") + b = _login(client, "13800003009") + _bind(client, b, _my_code(client, a)) + _report_compare(client, b, "trace-iso-1") + + with SessionLocal() as db: + ua = get_user_by_phone(db, "13800003008") + acc = db.get(CoinAccount, ua.id) + assert acc.invite_cash_balance_cents == INVITE_COMPARE_REWARD_CENTS # 奖励金到账 + assert acc.cash_balance_cents == 0 # 没串进金币现金 + txns = db.execute( + select(InviteCashTransaction).where( + InviteCashTransaction.user_id == ua.id, + InviteCashTransaction.biz_type == "invite_reward", + ) + ).scalars().all() + assert len(txns) == 1 + assert txns[0].amount_cents == INVITE_COMPARE_REWARD_CENTS diff --git a/tests/test_welfare.py b/tests/test_welfare.py index d011907..07af7bc 100644 --- a/tests/test_welfare.py +++ b/tests/test_welfare.py @@ -46,7 +46,12 @@ def test_account_auto_created_empty(client) -> None: r = client.get("/api/v1/wallet/account", headers=_auth(token)) assert r.status_code == 200, r.text body = r.json() - assert body == {"coin_balance": 0, "cash_balance_cents": 0, "total_coin_earned": 0} + assert body == { + "coin_balance": 0, + "cash_balance_cents": 0, + "invite_cash_balance_cents": 0, # v2 账户隔离新增(邀请奖励金,与现金隔离) + "total_coin_earned": 0, + } def test_signin_flow(client) -> None: