feat(invite): 邀请奖励金账户(与金币隔离) + 比价发奖 + 奖励金提现
- 新增邀请奖励金独立账本: coin_account 加余额列 + invite_cash_transaction 流水表, 与金币现金物理隔离 - bind 改 v2: 绑定只发被邀请人新人金币; 邀请人改在好友首次成功比价时发 2 元奖励金(幂等只发一次) - 提现链路按 source 分流: 扣款/退款/对账/分页各认账户, 两本账不串 - /invite/me 返回奖励金余额/累计提现/7天倒计时 - 新增 migration(建表加列) + 比价发奖与提现隔离测试
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -127,6 +127,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 转成元;
|
||||
|
||||
@@ -36,5 +36,6 @@ from app.models.wallet import ( # noqa: F401
|
||||
CashTransaction,
|
||||
CoinAccount,
|
||||
CoinTransaction,
|
||||
InviteCashTransaction,
|
||||
WithdrawOrder,
|
||||
)
|
||||
|
||||
+15
-1
@@ -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
|
||||
)
|
||||
|
||||
@@ -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}>"
|
||||
|
||||
@@ -34,6 +34,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 有邀请码(懒生成),返回它。唯一约束碰撞则换码重试。
|
||||
|
||||
@@ -96,9 +120,10 @@ class BindResult:
|
||||
def bind(
|
||||
db: Session, *, invitee: User, invite_code: str, channel: str = "clipboard"
|
||||
) -> 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)
|
||||
@@ -114,7 +139,8 @@ def bind(
|
||||
if not _is_new_user(invitee):
|
||||
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
|
||||
|
||||
rel = InviteRelation(
|
||||
@@ -122,15 +148,11 @@ def bind(
|
||||
invitee_user_id=invitee.id,
|
||||
channel=(channel or "clipboard")[:16],
|
||||
status="effective",
|
||||
inviter_coin=inviter_coin,
|
||||
inviter_coin=0, # 邀请人改比价发现金,绑定不发金币
|
||||
invitee_coin=invitee_coin,
|
||||
)
|
||||
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="邀请好友奖励",
|
||||
)
|
||||
# 只给被邀请人发新人金币(同事务,与建关系一起 commit);邀请人那份移到比价发奖。
|
||||
crud_wallet.grant_coins(
|
||||
db, invitee.id, invitee_coin,
|
||||
biz_type="invite_invitee", ref_id=str(inviter.id), remark="新人受邀奖励",
|
||||
@@ -153,6 +175,48 @@ def bind(
|
||||
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]:
|
||||
"""返回 (已成功邀请人数, 累计从邀请获得的金币)。"""
|
||||
count = db.execute(
|
||||
@@ -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。前端拿不到完整号,展示被邀请人时在此兜底名字。
|
||||
|
||||
|
||||
+84
-32
@@ -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 <col> >= 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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user