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
+1
View File
@@ -36,5 +36,6 @@ from app.models.wallet import ( # noqa: F401
CashTransaction,
CoinAccount,
CoinTransaction,
InviteCashTransaction,
WithdrawOrder,
)
+15 -1
View File
@@ -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
)
+48
View File
@@ -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"<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}>"