feat(invite): 邀请奖励金账户(与金币隔离) + 比价发奖 + 奖励金提现

- 新增邀请奖励金独立账本: coin_account 加余额列 + invite_cash_transaction 流水表, 与金币现金物理隔离
- bind 改 v2: 绑定只发被邀请人新人金币; 邀请人改在好友首次成功比价时发 2 元奖励金(幂等只发一次)
- 提现链路按 source 分流: 扣款/退款/对账/分页各认账户, 两本账不串
- /invite/me 返回奖励金余额/累计提现/7天倒计时
- 新增 migration(建表加列) + 比价发奖与提现隔离测试
This commit is contained in:
xiebing
2026-06-26 15:50:20 +08:00
parent e13f0f7658
commit 5adf90dbff
16 changed files with 910 additions and 172 deletions
@@ -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')
+13
View File
@@ -21,6 +21,7 @@ from app.api.deps import CurrentUser, DbSession
from app.db.session import SessionLocal from app.db.session import SessionLocal
from app.models.comparison import ComparisonRecord from app.models.comparison import ComparisonRecord
from app.repositories import comparison as crud_compare 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.services.pricebot_llm_calls import fetch_llm_calls
from app.schemas.compare_record import ( from app.schemas.compare_record import (
CompareStatsOut, CompareStatsOut,
@@ -52,6 +53,18 @@ def report_record(
# 任务做,不阻塞上报响应(顺带给 pricebot 落盘留足余量)。upsert 已 commit,后台用 # 任务做,不阻塞上报响应(顺带给 pricebot 落盘留足余量)。upsert 已 commit,后台用
# 独立 session 按 record id 回填 llm_calls + 派生 llm_call_count/retry_count。 # 独立 session 按 record id 回填 llm_calls + 派生 llm_call_count/retry_count。
background_tasks.add_task(_backfill_llm_calls, rec.id, rec.trace_id) 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( logger.info(
"compare record user=%s trace=%s biz=%s status=%s saved=%s (llm_calls backfill queued)", "compare record user=%s trace=%s biz=%s status=%s saved=%s (llm_calls backfill queued)",
user.id, user.id,
+7
View File
@@ -84,6 +84,8 @@ def _parse_device_model(ua: str) -> str:
def my_invite(user: CurrentUser, db: DbSession) -> InviteInfoOut: def my_invite(user: CurrentUser, db: DbSession) -> InviteInfoOut:
code = invite_repo.ensure_code(db, user) code = invite_repo.ensure_code(db, user)
invited, coins = invite_repo.get_stats(db, user.id) 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 "?" sep = "&" if "?" in settings.INVITE_LANDING_URL else "?"
share_url = f"{settings.INVITE_LANDING_URL}{sep}ref={code}" share_url = f"{settings.INVITE_LANDING_URL}{sep}ref={code}"
return InviteInfoOut( return InviteInfoOut(
@@ -91,6 +93,11 @@ def my_invite(user: CurrentUser, db: DbSession) -> InviteInfoOut:
share_url=share_url, share_url=share_url,
invited_count=invited, invited_count=invited,
coins_earned=coins, 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,
) )
+4 -2
View File
@@ -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") raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="wechat pay not configured")
try: try:
order = crud_wallet.create_withdraw( 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: except crud_wallet.InvalidWithdrawAmountError as e:
raise HTTPException( raise HTTPException(
@@ -274,10 +275,11 @@ def withdraw_status(
def withdraw_orders( def withdraw_orders(
user: CurrentUser, user: CurrentUser,
db: DbSession, db: DbSession,
source: str | None = Query(None, description="按账户来源过滤:coin_cash / invite_cash;不传=全部"),
limit: int = Query(20, ge=1, le=100), limit: int = Query(20, ge=1, le=100),
cursor: int | None = Query(None, description="上一页末条 id"), cursor: int | None = Query(None, description="上一页末条 id"),
) -> WithdrawOrderPage: ) -> 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( return WithdrawOrderPage(
items=[WithdrawOrderOut.model_validate(it) for it in items], items=[WithdrawOrderOut.model_validate(it) for it in items],
next_cursor=next_cursor, next_cursor=next_cursor,
+7
View File
@@ -127,6 +127,13 @@ INVITE_NEW_USER_WINDOW_HOURS: int = 72
INVITE_FP_WINDOW_DAYS: int = 7 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(),官方口径单位是【分/千次展示】(不是元! # eCPM 取自穿山甲 SDK getShowEcpm().getEcpm(),官方口径单位是【分/千次展示】(不是元!
# csjplatform 文档原文"通过 getEcpm 获取的单位是分")。计算时先 ÷100 转成元; # csjplatform 文档原文"通过 getEcpm 获取的单位是分")。计算时先 ÷100 转成元;
+1
View File
@@ -36,5 +36,6 @@ from app.models.wallet import ( # noqa: F401
CashTransaction, CashTransaction,
CoinAccount, CoinAccount,
CoinTransaction, CoinTransaction,
InviteCashTransaction,
WithdrawOrder, WithdrawOrder,
) )
+15 -1
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
from datetime import datetime 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 sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base from app.db.base import Base
@@ -35,6 +35,20 @@ class InviteRelation(Base):
inviter_coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0) inviter_coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
invitee_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( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
) )
+48
View File
@@ -25,6 +25,11 @@ class CoinAccount(Base):
) )
coin_balance: Mapped[int] = mapped_column(Integer, nullable=False, default=0) 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: 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) 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),唯一
out_bill_no: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) out_bill_no: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
amount_cents: Mapped[int] = mapped_column(Integer, 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) user_name: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 归一化状态:reviewing(待审核) / pending(打款在途) / success / failed(打款失败已退) / rejected(审核拒绝已退) # 归一化状态:reviewing(待审核) / pending(打款在途) / success / failed(打款失败已退) / rejected(审核拒绝已退)
@@ -200,3 +210,41 @@ class CashTransaction(Base):
def __repr__(self) -> str: # pragma: no cover def __repr__(self) -> str: # pragma: no cover
return f"<CashTransaction id={self.id} user_id={self.user_id} cents={self.amount_cents}>" return f"<CashTransaction id={self.id} user_id={self.user_id} cents={self.amount_cents}>"
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"<InviteCashTransaction id={self.id} user_id={self.user_id} cents={self.amount_cents}>"
+93 -9
View File
@@ -34,6 +34,30 @@ def _gen_code() -> str:
return "".join(secrets.choice(_CODE_ALPHABET) for _ in range(_CODE_LEN)) 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: def ensure_code(db: Session, user: User) -> str:
"""保证 user 有邀请码(懒生成),返回它。唯一约束碰撞则换码重试。 """保证 user 有邀请码(懒生成),返回它。唯一约束碰撞则换码重试。
@@ -96,9 +120,10 @@ class BindResult:
def bind( def bind(
db: Session, *, invitee: User, invite_code: str, channel: str = "clipboard" db: Session, *, invitee: User, invite_code: str, channel: str = "clipboard"
) -> BindResult: ) -> BindResult:
"""把 invitee 绑定到 invite_code 对应的邀请人,注册即生效 + 双方发金币 """把 invitee 绑定到 invite_code 对应的邀请人,注册即生效。
幂等:invitee 已被绑过 already_bound(不重复发奖) v2 发奖( 2026-06-23 拍板):被邀请人绑定即得新人金币(拉新即时激励);邀请人不在此发,
"好友比价才发 2 元邀请奖励金"( try_reward_on_compare,防刷)幂等:已绑过 already_bound
""" """
# 幂等:已绑过直接返回(不重复发奖) # 幂等:已绑过直接返回(不重复发奖)
existing = _relation_of_invitee(db, invitee.id) existing = _relation_of_invitee(db, invitee.id)
@@ -114,7 +139,8 @@ def bind(
if not _is_new_user(invitee): if not _is_new_user(invitee):
return BindResult("not_eligible") return BindResult("not_eligible")
inviter_coin = rewards.INVITE_INVITER_COINS # v2(冰 2026-06-23 拍板):邀请人不再在绑定时发金币,改"好友比价才发 2 元邀请奖励金"
# (见 try_reward_on_compare,防刷);被邀请人保留绑定即得新人金币(拉新即时激励)。
invitee_coin = rewards.INVITE_INVITEE_COINS invitee_coin = rewards.INVITE_INVITEE_COINS
rel = InviteRelation( rel = InviteRelation(
@@ -122,15 +148,11 @@ def bind(
invitee_user_id=invitee.id, invitee_user_id=invitee.id,
channel=(channel or "clipboard")[:16], channel=(channel or "clipboard")[:16],
status="effective", status="effective",
inviter_coin=inviter_coin, inviter_coin=0, # 邀请人改比价发现金,绑定不发金币
invitee_coin=invitee_coin, invitee_coin=invitee_coin,
) )
db.add(rel) db.add(rel)
# 双方发金币(同事务,与建关系一起 commit)。ref_id 互指对方便于对账 # 只给被邀请人发新人金币(同事务,与建关系一起 commit);邀请人那份移到比价发奖
crud_wallet.grant_coins(
db, inviter.id, inviter_coin,
biz_type="invite_inviter", ref_id=str(invitee.id), remark="邀请好友奖励",
)
crud_wallet.grant_coins( crud_wallet.grant_coins(
db, invitee.id, invitee_coin, db, invitee.id, invitee_coin,
biz_type="invite_invitee", ref_id=str(inviter.id), remark="新人受邀奖励", biz_type="invite_invitee", ref_id=str(inviter.id), remark="新人受邀奖励",
@@ -153,6 +175,48 @@ def bind(
return BindResult("success", rel, invitee_coin) return BindResult("success", rel, invitee_coin)
@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]: def get_stats(db: Session, inviter_id: int) -> tuple[int, int]:
"""返回 (已成功邀请人数, 累计从邀请获得的金币)。""" """返回 (已成功邀请人数, 累计从邀请获得的金币)。"""
count = db.execute( count = db.execute(
@@ -167,6 +231,26 @@ def get_stats(db: Session, inviter_id: int) -> tuple[int, int]:
return int(count), int(coins) 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: def _mask_phone(phone: str) -> str:
"""手机号脱敏:138****8888。前端拿不到完整号,展示被邀请人时在此兜底名字。 """手机号脱敏:138****8888。前端拿不到完整号,展示被邀请人时在此兜底名字。
+84 -32
View File
@@ -24,6 +24,7 @@ from app.models.wallet import (
CashTransaction, CashTransaction,
CoinAccount, CoinAccount,
CoinTransaction, CoinTransaction,
InviteCashTransaction,
WechatTransferAuthorization, WechatTransferAuthorization,
WithdrawOrder, WithdrawOrder,
) )
@@ -166,6 +167,36 @@ def grant_cash(
return acc, txn 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( def list_coin_transactions(
db: Session, db: Session,
user_id: int, 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}$") _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 <col> >= amount`)避免"读-判断-写"竞态并发/重试时不会两次
都通过余额检查导致超额扣款(SQLite 串行写Postgres 行级,均安全)source 决定扣
cash_balance_cents(coin_cash) 还是 invite_cash_balance_cents(invite_cash)
""" """
col = _balance_col(source)
res = db.execute( res = db.execute(
update(CoinAccount) update(CoinAccount)
.where( .where(CoinAccount.user_id == user_id, col >= amount_cents)
CoinAccount.user_id == user_id, .values({col: col - amount_cents})
CoinAccount.cash_balance_cents >= amount_cents,
)
.values(cash_balance_cents=CoinAccount.cash_balance_cents - amount_cents)
) )
return res.rowcount == 1 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( db.execute(
update(CoinAccount) update(CoinAccount)
.where(CoinAccount.user_id == user_id) .where(CoinAccount.user_id == user_id)
.values(cash_balance_cents=CoinAccount.cash_balance_cents + amount_cents) .values({col: col + amount_cents})
) )
db.flush() db.flush()
bal = db.execute( bal = db.execute(
select(CoinAccount.cash_balance_cents).where(CoinAccount.user_id == user_id) select(col).where(CoinAccount.user_id == user_id)
).scalar_one() ).scalar_one()
return bal return bal
@@ -456,11 +497,15 @@ def _refund_withdraw(
""" """
if order.status in ("failed", "rejected"): if order.status in ("failed", "rejected"):
return # 防重复退款(并发/对账与查单/重复拒绝同时触发) 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( refunded_txn_id = db.execute(
select(CashTransaction.id).where( select(txn_model.id).where(
CashTransaction.user_id == order.user_id, txn_model.user_id == order.user_id,
CashTransaction.biz_type == "withdraw_refund", txn_model.biz_type == refund_biz,
CashTransaction.ref_id == order.out_bill_no, txn_model.ref_id == order.out_bill_no,
).limit(1) ).limit(1)
).scalar_one_or_none() ).scalar_one_or_none()
if refunded_txn_id is not None: if refunded_txn_id is not None:
@@ -468,13 +513,13 @@ def _refund_withdraw(
order.fail_reason = reason[:256] order.fail_reason = reason[:256]
db.commit() db.commit()
return 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( db.add(
CashTransaction( txn_model(
user_id=order.user_id, user_id=order.user_id,
amount_cents=order.amount_cents, amount_cents=order.amount_cents,
balance_after_cents=bal, balance_after_cents=bal,
biz_type="withdraw_refund", biz_type=refund_biz,
ref_id=order.out_bill_no, ref_id=order.out_bill_no,
# 用户可见文案区分"未成功(自动退)"vs"审核未通过";技术原因记在 order.fail_reason # 用户可见文案区分"未成功(自动退)"vs"审核未通过";技术原因记在 order.fail_reason
remark=( remark=(
@@ -492,13 +537,13 @@ def _refund_withdraw(
db.commit() db.commit()
except IntegrityError: except IntegrityError:
# 并发退款兜底:唯一退款流水已被另一事务写入时,回滚本事务的加钱和流水, # 并发退款兜底:唯一退款流水已被另一事务写入时,回滚本事务的加钱和流水,
# 再把订单状态补到终态。这样无论拒绝/查单/对账怎么并发,金最多退一次。 # 再把订单状态补到终态。这样无论拒绝/查单/对账怎么并发,金最多退一次。
db.rollback() db.rollback()
refunded_txn_id = db.execute( refunded_txn_id = db.execute(
select(CashTransaction.id).where( select(txn_model.id).where(
CashTransaction.user_id == user_id, txn_model.user_id == user_id,
CashTransaction.biz_type == "withdraw_refund", txn_model.biz_type == refund_biz,
CashTransaction.ref_id == out_bill_no, txn_model.ref_id == out_bill_no,
).limit(1) ).limit(1)
).scalar_one_or_none() ).scalar_one_or_none()
if refunded_txn_id is None: if refunded_txn_id is None:
@@ -562,6 +607,7 @@ def create_withdraw(
user_id: int, user_id: int,
amount_cents: int, amount_cents: int,
*, *,
source: str = "coin_cash",
user_name: str | None = None, user_name: str | None = None,
out_bill_no: str | None = None, out_bill_no: str | None = None,
) -> WithdrawOrder: ) -> WithdrawOrder:
@@ -608,20 +654,23 @@ def create_withdraw(
# 账户须存在(原子扣款的 UPDATE 不会建账户) # 账户须存在(原子扣款的 UPDATE 不会建账户)
get_or_create_account(db, user_id, commit=True) get_or_create_account(db, user_id, commit=True)
# #1 原子扣款:余额不足时影响行数为 0 # #1 原子扣款:余额不足时影响行数为 0(按 source 扣对应账户)
if not _try_deduct_cash(db, user_id, amount_cents): if not _try_deduct_cash(db, user_id, amount_cents, source):
db.rollback() db.rollback()
raise InsufficientCashError 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( 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() ).scalar_one()
db.add( db.add(
CashTransaction( txn_model(
user_id=user_id, user_id=user_id,
amount_cents=-amount_cents, amount_cents=-amount_cents,
balance_after_cents=bal, balance_after_cents=bal,
biz_type="withdraw", biz_type=withdraw_biz,
ref_id=out_bill_no, ref_id=out_bill_no,
remark="提现到微信零钱(待审核)", remark="提现到微信零钱(待审核)",
created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None), created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
@@ -631,6 +680,7 @@ def create_withdraw(
user_id=user_id, user_id=user_id,
out_bill_no=out_bill_no, out_bill_no=out_bill_no,
amount_cents=amount_cents, amount_cents=amount_cents,
source=source,
user_name=user_name, user_name=user_name,
status="reviewing", status="reviewing",
) )
@@ -1007,10 +1057,12 @@ def reconcile_pending_withdraws(db: Session, *, older_than_minutes: int = 15) ->
def list_withdraw_orders( 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]: ) -> tuple[list[WithdrawOrder], int | None]:
"""提现单分页(按 id 倒序,游标式)。""" """提现单分页(按 id 倒序,游标式)。source 非空时只返回该账户来源的单(coin_cash / invite_cash)。"""
stmt = select(WithdrawOrder).where(WithdrawOrder.user_id == user_id) 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: if cursor is not None:
stmt = stmt.where(WithdrawOrder.id < cursor) stmt = stmt.where(WithdrawOrder.id < cursor)
stmt = stmt.order_by(WithdrawOrder.id.desc()).limit(limit) stmt = stmt.order_by(WithdrawOrder.id.desc()).limit(limit)
+6 -1
View File
@@ -10,7 +10,12 @@ class InviteInfoOut(BaseModel):
invite_code: str # 我的邀请码 invite_code: str # 我的邀请码
share_url: str # 落地页链接(含 ?ref=),前端据此生成二维码 + 复制分享 share_url: str # 落地页链接(含 ?ref=),前端据此生成二维码 + 复制分享
invited_count: int # 已成功邀请人数 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): class LandingTrackIn(BaseModel):
+3
View File
@@ -16,6 +16,7 @@ class CoinAccountOut(BaseModel):
coin_balance: int = Field(..., description="当前金币余额") coin_balance: int = Field(..., description="当前金币余额")
cash_balance_cents: int = Field(..., description="当前现金余额(分)") cash_balance_cents: int = Field(..., description="当前现金余额(分)")
invite_cash_balance_cents: int = Field(0, description="邀请奖励金余额(分,与现金隔离)")
total_coin_earned: int = Field(..., description="累计赚取金币") total_coin_earned: int = Field(..., description="累计赚取金币")
@@ -123,6 +124,7 @@ class UnbindWechatResultOut(BaseModel):
class WithdrawRequest(BaseModel): class WithdrawRequest(BaseModel):
amount_cents: int = Field(..., gt=0, description="提现金额(分)") amount_cents: int = Field(..., gt=0, description="提现金额(分)")
source: str = Field("coin_cash", description="提现账户:coin_cash(金币现金) / invite_cash(邀请奖励金)")
user_name: str | None = Field(None, description="实名(达额时微信要求,可空)") user_name: str | None = Field(None, description="实名(达额时微信要求,可空)")
out_bill_no: str | None = Field( out_bill_no: str | None = Field(
None, description="客户端幂等键(商户单号):同号重试不重复转账。不传则服务端生成" None, description="客户端幂等键(商户单号):同号重试不重复转账。不传则服务端生成"
@@ -166,6 +168,7 @@ class WithdrawOrderOut(BaseModel):
id: int id: int
out_bill_no: str out_bill_no: str
amount_cents: int amount_cents: int
source: str = Field("coin_cash", description="提现账户:coin_cash / invite_cash")
status: str = Field(..., description="reviewing(待审核) / pending / success / failed / rejected") status: str = Field(..., description="reviewing(待审核) / pending / success / failed / rejected")
wechat_state: str | None = None wechat_state: str | None = None
fail_reason: str | None = None fail_reason: str | None = None
+224 -117
View File
@@ -8,122 +8,231 @@
<style> <style>
* { margin:0; padding:0; box-sizing:border-box; -webkit-tap-highlight-color:transparent; } * { margin:0; padding:0; box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
html,body { height:100%; } html,body { height:100%; }
button { border:0; background:none; color:inherit; font:inherit; cursor:pointer; }
body { body {
font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif; min-height:100vh;
background:linear-gradient(165deg,#FF7A3D 0%,#FF3B30 52%,#E0245E 100%); display:flex; align-items:center; justify-content:center;
color:#fff; min-height:100%; display:flex; flex-direction:column; background:#000;
align-items:center; justify-content:center; padding:40px 26px; text-align:center; font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Helvetica Neue",sans-serif;
overflow-x:hidden;
} }
.logo { /* 设备框:桌面预览成 375×667 卡片;真机(≤430)铺满全屏 */
width:104px; height:104px; border-radius:26px; background:#fff; .device {
display:flex; align-items:center; justify-content:center; font-size:52px; position:relative; flex:0 0 auto;
box-shadow:0 14px 34px rgba(0,0,0,.22); margin-bottom:24px; width:375px; height:667px;
overflow:hidden; border-radius:32px;
background:#FFF4CD;
box-shadow:0 20px 60px rgba(0,0,0,.5);
color:#fff;
} }
h1 { font-size:30px; font-weight:800; letter-spacing:1px; } .download-landing {
.slogan { margin-top:12px; font-size:16px; line-height:1.7; opacity:.95; max-width:300px; } position:absolute; inset:0; z-index:0; overflow:hidden;
.feats { margin-top:26px; display:flex; flex-direction:column; gap:12px; width:100%; max-width:320px; } background:
.feat { background:rgba(255,255,255,.16); border-radius:14px; padding:13px 16px; font-size:15px; display:flex; align-items:center; gap:10px; } url('coupon-page-bg.png') center center / cover no-repeat,
.feat b { font-weight:700; } #FFF4CD;
.btn { color:#1A1A1A; text-align:center;
margin-top:34px; width:100%; max-width:320px; border:none; cursor:pointer; }
background:#fff; color:#FF3B30; font-size:19px; font-weight:800; .download-content {
padding:17px 0; border-radius:999px; box-shadow:0 10px 26px rgba(0,0,0,.22); position:relative; z-index:1; height:100%;
display:flex; align-items:center; justify-content:center; gap:9px; padding:82px 20px 0;
display:flex; flex-direction:column; align-items:center; overflow:hidden;
}
.brand-lockup {
display:flex; align-items:center; justify-content:center; gap:16px;
}
.ad-logo {
width:53px; height:53px; border-radius:13px; display:block;
box-shadow:0 10px 24px rgba(255,179,0,.24);
}
.ad-title {
color:#1A1A1A; font-size:32px; font-weight:800; line-height:1.15;
letter-spacing:0; white-space:nowrap;
}
.ad-subtitle {
margin-top:22px; max-width:100%;
color:#000; font-size:18px; font-weight:400; line-height:1.25;
display:flex; align-items:center; justify-content:center; gap:10px; white-space:nowrap;
}
.ad-subtitle::before, .ad-subtitle::after {
content:""; width:5px; height:5px; border-radius:50%; background:#000; flex:0 0 auto;
}
.bottom-area {
position:relative; z-index:2; width:100%; max-width:266px;
margin-top:11px;
display:grid; grid-template-columns:1fr; justify-items:stretch; align-content:center; gap:10px;
}
.download-btn {
width:100%; height:39px; border-radius:22px; color:#1A1A1A;
font-family:inherit; font-size:14px; line-height:1; font-weight:700; letter-spacing:0;
display:flex; align-items:center; justify-content:center;
}
.download-btn.primary {
background:linear-gradient(180deg,#FFE066 0%,#FFC400 100%);
box-shadow:inset 0 1px 0 rgba(255,255,255,.82), 0 7px 18px rgba(255,179,0,.24);
}
.download-btn.secondary {
background:#fff; border:1px solid #DDD; color:#1A1A1A; font-size:13px;
box-shadow:0 2px 8px rgba(122,79,0,.08);
}
.download-btn:active { transform:translateY(1px); }
/* 底部两条卖点文案,压在背景插画两张卡片下方 */
/* 卖点卡:CSS 实体卡片(白底+图标),不再靠底图死框,字自适应(对齐 WeChat.html PR siyi 改版)*/
.download-feature-card {
position:absolute; z-index:2; pointer-events:none;
height:62px; padding:0 10px; border-radius:22px;
background:#FFFAEE;
box-shadow:inset 0 1px 0 rgba(255,255,255,.86), 0 6px 14px rgba(122,79,0,.08);
display:flex; align-items:center; gap:8px; color:#1A1A1A;
}
.download-feature-card.left { left:24px; top:576px; width:146px; }
.download-feature-card.right { left:199px; top:576px; width:156px; }
.download-feature-icon {
width:27px; height:27px; flex:0 0 auto; display:block; color:#FFAE00;
}
.download-feature-icon svg {
display:block; width:100%; height:100%; filter:drop-shadow(0 1px 0 rgba(255,255,255,.7));
}
.download-feature-copy {
min-width:0; flex:1 1 auto; text-align:left; white-space:nowrap; letter-spacing:0;
}
.download-feature-title {
display:block; font-size:13px; font-weight:800; line-height:1.12; letter-spacing:0;
}
.download-feature-desc {
display:block; margin-top:5px; color:#5A3A00; font-size:10px; font-weight:400; line-height:1.1;
} }
.btn:active { transform:translateY(1px); opacity:.92; }
.hint { margin-top:16px; font-size:13px; opacity:.85; }
.foot { margin-top:30px; font-size:12px; opacity:.6; line-height:1.6; max-width:320px; }
/* 微信内"去浏览器打开"引导蒙层 */ /* 微信内"去浏览器打开"引导蒙层 */
#wxmask { .download-guide-layer {
display:none; position:fixed; inset:0; z-index:9999; position:absolute; inset:0; z-index:30; display:none;
background:rgba(0,0,0,.86); padding:18px; background:rgba(0,0,0,.85); color:#fff; /* 半透明黑:透出底层(新版)下载页,隐约可见 */
}
.download-guide-layer.show { display:block; }
.download-guide-arrow {
position:absolute; top:17px; right:9px; width:80px; height:60px;
}
.download-guide-arrow-svg {
display:block; width:100%; height:100%; overflow:hidden; shape-rendering:geometricPrecision;
}
.download-guide-title {
position:absolute; top:129px; right:16px; width:218px; margin:0;
color:#fff; font-size:17px; font-weight:800; line-height:1.32; letter-spacing:0;
text-align:right; text-shadow:0 2px 8px rgba(0,0,0,.36);
}
.download-guide-title .guide-dots { color:#FFD95A; letter-spacing:4px; }
.download-guide-title .guide-highlight { color:#FFD95A; white-space:nowrap; }
.download-guide-title .guide-final {
display:block; margin-top:10px; color:rgba(255,255,255,.94);
font-size:13px; font-weight:700; line-height:1.38;
}
.download-guide-title .guide-target { color:#FFD95A; white-space:nowrap; }
.guide-dismiss {
position:absolute; bottom:30px; left:0; right:0; text-align:center;
font-size:14px; color:#fff; opacity:.75;
}
.toast {
position:absolute; left:50%; bottom:118px; z-index:20;
transform:translateX(-50%) translateY(12px);
padding:9px 14px; border-radius:12px; background:rgba(0,0,0,.78);
color:#fff; font-size:14px; opacity:0; pointer-events:none;
transition:opacity .2s ease, transform .2s ease;
}
.toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
@media (max-width:430px) {
/* 真机:宽满屏、高按 375:667 锁比例(不拉伸变形),顶对齐、底部留白用底色填。
这样底图(含价格卡)与卖点卡片同处 667 坐标基准,卡片用回 top:576,不再相互错位/遮挡。 */
body { align-items:flex-start; background:#FFF4CD; }
.device { width:100vw; height:calc(100vw * 667 / 375); border-radius:0; box-shadow:none; }
} }
#wxmask.show { display:block; }
.arrow { position:absolute; top:8px; right:14px; width:120px; }
.wxtip { position:absolute; top:150px; right:18px; left:18px; text-align:right; }
.wxtip .big { font-size:21px; font-weight:800; line-height:1.5; }
.wxtip .big em { color:#FFD24D; font-style:normal; }
.wxtip .sub { margin-top:14px; font-size:15px; line-height:1.8; opacity:.9; }
.wxsteps { margin-top:26px; text-align:left; background:rgba(255,255,255,.1); border-radius:14px; padding:18px 18px; font-size:15px; line-height:2; }
.wxsteps .n { display:inline-block; width:22px; height:22px; line-height:22px; text-align:center; border-radius:50%; background:#FFD24D; color:#333; font-weight:800; font-size:13px; margin-right:8px; }
.closebar { position:absolute; bottom:30px; left:0; right:0; text-align:center; font-size:14px; opacity:.7; }
</style> </style>
</head> </head>
<body> <body>
<div class="logo">🛒</div> <main class="device" aria-label="傻瓜比价下载页">
<h1>傻瓜比价</h1> <section class="download-landing" aria-label="傻瓜比价下载页">
<div class="slogan">买什么都先比一比<br>自动帮你找全网最低价</div> <div class="download-content">
<div class="brand-lockup">
<div class="feats"> <img class="ad-logo" src="sb-brand.png" alt="傻瓜比价">
<div class="feat">🍔 <span>点外卖前一键比价,<b>美团/京东/淘宝</b>到手价一目了然</span></div> <h1 class="ad-title">傻瓜比价</h1>
<div class="feat">🎟️ <span>自动领遍各平台<b>红包券</b>,能省的一分不漏</span></div> </div>
<div class="feat">💰 <span>省下的钱看得见,还能<b>赚金币提现</b></span></div> <p class="ad-subtitle">跨平台比价,用傻瓜</p>
</div> <div class="bottom-area" aria-label="下载入口">
<button class="download-btn primary" id="dlbtn" type="button">应用商店下载</button>
<button class="btn" id="dlbtn">🏪 打开应用商店下载</button> <button class="download-btn secondary" id="dlbtn2" type="button">官网下载</button>
<div class="hint" id="hint">Android 安卓版 · 应用商店安全下载</div>
<div class="foot">
将前往应用商店下载,安全放心。<br>
本页为内部测试页。
</div>
<!-- 微信内引导:跳出微信去浏览器 -->
<div id="wxmask">
<svg class="arrow" viewBox="0 0 120 130" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M30 120 C 30 70, 55 40, 95 28" stroke="#FFD24D" stroke-width="6" stroke-linecap="round" fill="none" stroke-dasharray="2 13"/>
<path d="M95 28 L 78 30 M95 28 L 92 46" stroke="#FFD24D" stroke-width="6" stroke-linecap="round"/>
</svg>
<div class="wxtip">
<div class="big">点击右上角 <em>···</em><br>选择「<em>在浏览器打开</em></div>
<div class="sub">微信里无法直接下载安装包<br>需在系统浏览器中完成下载</div>
<div class="wxsteps">
<div><span class="n">1</span>点右上角的 ··· 菜单</div>
<div><span class="n">2</span>选择「在浏览器打开」</div>
<div><span class="n">3</span>在浏览器里按提示去应用商店下载</div>
</div> </div>
</div> </div>
<div class="closebar" id="wxclose">我知道了 ✕</div> <div class="download-feature-card left" aria-label="优惠券轻松领,羊毛全都不错过">
<span class="download-feature-icon" aria-hidden="true">
<svg viewBox="0 0 40 32" focusable="false">
<path d="M5 5h30a3 3 0 0 1 3 3v5.2a4.8 4.8 0 0 0 0 9.6V24a3 3 0 0 1-3 3H5a3 3 0 0 1-3-3v-1.2a4.8 4.8 0 0 0 0-9.6V8a3 3 0 0 1 3-3Z" fill="currentColor"/>
<path d="M20 10v12" fill="none" stroke="#FFF7CF" stroke-width="3" stroke-linecap="round"/>
</svg>
</span>
<span class="download-feature-copy">
<span class="download-feature-title">优惠券轻松领</span>
<span class="download-feature-desc">羊毛全都不错过</span>
</span>
</div>
<div class="download-feature-card right" aria-label="一键全网找底价,再也不用费力切屏">
<span class="download-feature-icon" aria-hidden="true">
<svg viewBox="0 0 40 32" focusable="false">
<rect x="5" y="16" width="8" height="11" rx="2" fill="currentColor"/>
<rect x="16" y="9" width="8" height="18" rx="2" fill="currentColor"/>
<rect x="27" y="3" width="8" height="24" rx="2" fill="currentColor"/>
</svg>
</span>
<span class="download-feature-copy">
<span class="download-feature-title">一键全网找底价</span>
<span class="download-feature-desc">再也不用费力切屏</span>
</span>
</div>
</section>
<!-- 微信内引导:跳出微信去浏览器 -->
<div class="download-guide-layer" id="wxGuide" role="dialog" aria-modal="true" aria-labelledby="wxGuideTitle">
<div class="download-guide-arrow" aria-hidden="true">
<svg class="download-guide-arrow-svg" viewBox="0 0 80 60" focusable="false">
<path d="M0 60 C16 32 39 15 66 15" fill="none" stroke="#FFD95A" stroke-width="5.5" stroke-linecap="round"/>
<path d="M59 5 L77 14 L63 31" fill="none" stroke="#FFD95A" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<h2 class="download-guide-title" id="wxGuideTitle">
点击右上角 <span class="guide-dots">···</span><br>
选择「<span class="guide-highlight">在浏览器打开</span>
<span class="guide-final">在浏览器里按提示<span class="guide-target">去应用商店下载</span></span>
</h2>
<div class="guide-dismiss" id="wxGuideDismiss">我知道了 ✕</div>
</div> </div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
</main>
<script> <script>
// 应用商店跳转:用包名(App 唯一标识 = 身份证号)定位到傻瓜比价的商店下载页 // ===== 应用商店跳转用包名App 唯一标识定位到傻瓜比价的商店下载页 =====
// 主:market:// 唤起手机自带应用市场(华为/小米/OV);兜底:应用宝网页(任何浏览器都能开) // 主market:// 唤起手机自带应用市场华为/小米/OV);兜底应用宝网页任何浏览器都能开
var PKG = "com.jishisongfu.shaguabijia"; var PKG = "com.jishisongfu.shaguabijia";
var MARKET_URL = "market://details?id=" + PKG; var MARKET_URL = "market://details?id=" + PKG;
var YYB_URL = "https://a.app.qq.com/o/simple.jsp?pkgname=" + PKG; var YYB_URL = "https://a.app.qq.com/o/simple.jsp?pkgname=" + PKG;
var ua = navigator.userAgent || ""; var ua = navigator.userAgent || "";
var isWeChat = /MicroMessenger/i.test(ua); var isWeChat = /MicroMessenger/i.test(ua);
var isIOS = /iPhone|iPad|iPod/i.test(ua); var isIOS = /iPhone|iPad|iPod/i.test(ua);
var isAndroid = /Android/i.test(ua); var ref = new URLSearchParams(location.search).get("ref"); // 邀请码(来自二维码 URL ?ref=)
var ref = new URLSearchParams(location.search).get("ref"); // 邀请码(来自二维码 URL ?ref=)
// 【任务 3】指纹归因兜底:页面加载即上报访问者指纹,后端存 invite_fingerprint 表 // ===== 指纹归因兜底页面加载即上报访问者指纹后端存 invite_fingerprint 表 =====
// 当 APK 首启读剪贴板失败(被覆盖)时,客户端用 (IP+屏幕+UA 解析的手机型) 反查 // 当 APK 首启读剪贴板失败被覆盖)时,客户端用 (IP+屏幕+UA 机型) 反查 7 天内最近一条 → 撞出原邀请人。
// 本表 7 天内最近一条匹配 → 撞库出原邀请人 → 走原 bind 流程。 // 任何失败都 silent,不影响下载主流程。
// 任何失败都 silent(不影响下载主流程);后端 invalid_code/no_ip 也只返 200。
if (ref) { if (ref) {
// screen 报【物理像素】= CSS 像素 × devicePixelRatio,跟 Android dm.widthPixels(物理像素)对齐 // screen 报【物理像素】= CSS 像素 × devicePixelRatio跟 Android dm.widthPixels 对齐,否则撞不上库
// 不同设备 DPR 不同(常见 2/2.5/3/3.5),CSS 像素直接报会跟客户端不对齐 → 撞不上库。
var _dpr = window.devicePixelRatio || 1; var _dpr = window.devicePixelRatio || 1;
var _sw = Math.round(screen.width * _dpr); var _sw = Math.round(screen.width * _dpr);
var _sh = Math.round(screen.height * _dpr); var _sh = Math.round(screen.height * _dpr);
fetch("/api/v1/invite/landing-track", { fetch("/api/v1/invite/landing-track", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({ ref: ref, screen: _sw + "x" + _sh }),
ref: ref, }).catch(function () {}); // silent,绝不阻断下载
screen: _sw + "x" + _sh,
// IP / UA 服务端从 HTTP 头自动拿,无需 JS 上报
}),
}).catch(function () {}); // silent,绝不阻断下载
} }
// 把邀请码写进剪贴板,APK 首启读出完成归因(deferred deeplink 的关键一步)。 // ===== 把邀请码写进剪贴板APK 首启读出完成归因deferred deeplink=====
// 浏览器要求:必须在用户点击手势里调用 + HTTPS 下才允许写。 // 浏览器要求必须在用户点击手势里调用 + HTTPS 下才允许写。
function legacyCopy(payload) { // 老 webview / 无 clipboard API 兜底 function legacyCopy(payload) { // 老 webview / 无 clipboard API 兜底
try { try {
var ta = document.createElement("textarea"); var ta = document.createElement("textarea");
ta.value = payload; ta.style.position = "fixed"; ta.style.top = "-1000px"; ta.style.opacity = "0"; ta.value = payload; ta.style.position = "fixed"; ta.style.top = "-1000px"; ta.style.opacity = "0";
@@ -131,50 +240,48 @@
document.execCommand("copy"); document.body.removeChild(ta); document.execCommand("copy"); document.body.removeChild(ta);
} catch (e) {} } catch (e) {}
} }
function copyInviteCode() { // 返回 Promise(完成后才下载,避免异步写入被打断) function copyInviteCode() { // 返回 Promise完成后才下载避免异步写入被打断
if (!ref) return Promise.resolve(); if (!ref) return Promise.resolve();
var payload = "SGBJ_INVITE:" + ref; var payload = "SGBJ_INVITE:" + ref;
legacyCopy(payload); // 同步兜底:在用户手势内立刻 execCommand 写一次(最可靠) legacyCopy(payload); // 同步兜底在用户手势内立刻 execCommand 写一次最可靠
if (navigator.clipboard && navigator.clipboard.writeText) { if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(payload).catch(function () {}); // 现代 API 锦上添花,失败无妨 return navigator.clipboard.writeText(payload).catch(function () {});
} }
return Promise.resolve(); return Promise.resolve();
} }
// 跳应用商店:先尝试 market:// 唤起手机自带商店;若 2.5s 内页面没切后台 // 跳应用商店:先 market:// 唤起自带商店2.5s 内页面没切后台 → 兜底跳应用宝网页,不让用户卡死。
//(= 没有商店接管 market://),兜底跳应用宝网页下载页,不让用户卡死。
function openStore() { function openStore() {
var jumped = false; var jumped = false;
function markJumped() { jumped = true; } // 页面切到后台 = 商店已唤起 document.addEventListener("visibilitychange", function () { if (document.hidden) jumped = true; });
document.addEventListener("visibilitychange", function () { window.addEventListener("pagehide", function () { jumped = true; });
if (document.hidden) jumped = true; window.addEventListener("blur", function () { jumped = true; });
}); window.location.href = MARKET_URL;
window.addEventListener("pagehide", markJumped); setTimeout(function () { if (!jumped) window.location.href = YYB_URL; }, 2500);
window.addEventListener("blur", markJumped);
window.location.href = MARKET_URL; // 唤起自带应用市场
setTimeout(function () {
if (!jumped) window.location.href = YYB_URL; // 没唤起 → 应用宝网页兜底
}, 2500);
} }
var hint = document.getElementById("hint"); // ===== 微信内引导蒙层 =====
if (isIOS) hint.textContent = "检测到 iPhone · iOS 版请前往 App Store"; var wxGuide = document.getElementById("wxGuide");
function showWxGuide() { wxGuide.classList.add("show"); }
function hideWxGuide() { wxGuide.classList.remove("show"); }
document.getElementById("wxGuideDismiss").addEventListener("click", hideWxGuide);
if (isWeChat) showWxGuide(); // 微信里一进页面就提示去浏览器(微信内下载必被拦)
var mask = document.getElementById("wxmask"); function showToast(text) {
function showMask(){ mask.classList.add("show"); } var toast = document.getElementById("toast");
function hideMask(){ mask.classList.remove("show"); } toast.textContent = text; toast.classList.add("show");
document.getElementById("wxclose").addEventListener("click", hideMask); clearTimeout(showToast.timer);
showToast.timer = setTimeout(function () { toast.classList.remove("show"); }, 1400);
}
// 微信里一进页面就提示去浏览器打开(下载在微信内必被拦) // ===== 下载按钮:微信内引导去浏览器;iOS 提示;安卓写邀请码 + 跳应用商店 =====
if (isWeChat) showMask(); function handleDownload() {
if (isWeChat) { showWxGuide(); return; }
document.getElementById("dlbtn").addEventListener("click", function(){ if (isIOS) { alert("iOS 版即将上线,请前往 App Store 搜索「傻瓜比价」"); return; }
if (isWeChat) { showMask(); return; } // 微信内:引导去浏览器 copyInviteCode(); // 先把邀请码写进剪贴板(供 App 首启归因),链路丢了还有 landing-track 指纹兜底
if (isIOS) { alert("iOS 版即将上线,请前往 App Store 搜索「傻瓜比价」"); return; }
// 安卓浏览器:先把邀请码写进剪贴板(legacyCopy 同步写、最可靠),再跳应用商店。
// 剪贴板供 App 首启归因;链路长易丢时由指纹兜底(已上报 landing-track)接住。
copyInviteCode();
openStore(); openStore();
}); }
document.getElementById("dlbtn").addEventListener("click", handleDownload);
document.getElementById("dlbtn2").addEventListener("click", handleDownload);
</script> </script>
</body> </body>
</html> </html>
+10 -10
View File
@@ -11,7 +11,6 @@ from sqlalchemy import select
from app.core.rewards import ( from app.core.rewards import (
INVITE_FP_WINDOW_DAYS, INVITE_FP_WINDOW_DAYS,
INVITE_INVITEE_COINS, INVITE_INVITEE_COINS,
INVITE_INVITER_COINS,
INVITE_NEW_USER_WINDOW_HOURS, INVITE_NEW_USER_WINDOW_HOURS,
) )
from app.db.session import SessionLocal from app.db.session import SessionLocal
@@ -56,8 +55,8 @@ def test_invite_me_returns_stable_code(client) -> None:
assert _my_code(client, token) == body["invite_code"] assert _my_code(client, token) == body["invite_code"]
def test_bind_flow_both_get_coins(client) -> None: def test_bind_flow_invitee_gets_coins(client) -> None:
"""B 用 A 的码绑定 → 双方各得 1 万金币;A 战绩 +1""" """v2:B 用 A 的码绑定 → 只有被邀请人 B 得新人金币;邀请人 A 绑定时不发(改比价发现金)"""
a = _login(client, "13800002002") a = _login(client, "13800002002")
b = _login(client, "13800002003") b = _login(client, "13800002003")
a_code = _my_code(client, a) a_code = _my_code(client, a)
@@ -71,14 +70,14 @@ def test_bind_flow_both_get_coins(client) -> None:
assert res["status"] == "success" assert res["status"] == "success"
assert res["coins_awarded"] == INVITE_INVITEE_COINS assert res["coins_awarded"] == INVITE_INVITEE_COINS
# 双方金币到账 # 被邀请人金币到账;邀请人绑定时不发金币(v2 改"好友比价发 2 元邀请奖励金")
assert _coin_balance(client, b) == INVITE_INVITEE_COINS assert _coin_balance(client, b) == INVITE_INVITEE_COINS
assert _coin_balance(client, a) == INVITE_INVITER_COINS 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)) r = client.get("/api/v1/invite/me", headers=_auth(a))
assert r.json()["invited_count"] == 1 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: def test_bind_idempotent_no_double_reward(client) -> None:
@@ -217,8 +216,8 @@ def test_bind_by_fingerprint_success(client) -> None:
) )
assert r2.status_code == 200, r2.text assert r2.status_code == 200, r2.text
assert r2.json()["status"] == "success" assert r2.json()["status"] == "success"
# 双方各发金币 # v2:只被邀请人发新人金币;邀请人绑定不发(改比价发现金)
assert _coin_balance(client, a) == INVITE_INVITER_COINS assert _coin_balance(client, a) == 0
assert _coin_balance(client, b) == INVITE_INVITEE_COINS assert _coin_balance(client, b) == INVITE_INVITEE_COINS
@@ -322,7 +321,8 @@ def test_invitees_basic(client) -> None:
names = {it["display_name"] for it in body["items"]} names = {it["display_name"] for it in body["items"]}
assert names == expected_names assert names == expected_names
assert all(it["avatar_url"] is None for it in body["items"]) 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: def test_invitees_order_desc(client) -> None:
+193
View File
@@ -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"
+114
View File
@@ -0,0 +1,114 @@
"""邀请 v2 比价发奖测试:好友完成成功比价 → 邀请人得邀请奖励金(独立账户),幂等只发一次,
账户隔离不串金币/现金被邀请人保留"绑定即得新人金币" 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