32d04d7008
代码 review(本分支未提交 WIP)发现并修复: - 高: enable_notification 可重复领取的发放是无锁读-算-写,并发/连点会算出同一序号 双倍发钱。给 coin_transaction 加 (user_id,biz_type,ref_id) 部分唯一索引(仅 task_*), claim 撞唯一约束→IntegrityError 回滚兜底成 AlreadyClaimedError(409),不双发。 配套 alembic 迁移 coin_txn_task_ref_uq(挂 head=drop_force_onboarding)。 - 中: 加领取封顶 rewards.notification_max_claims(减半到底=1 后不再可领),挡通知一直关着 时无限刷 1 金币;列表领满后 claimed 置 True 供客户端隐藏。 - 中: _notification_grant_times 改只数 amount>0 流水,避免日后冲正/负向流水把次数算大。 - 低: 合并 ops_marquee._nickname 两个一字不差的重复分支。 - 文档: tasks-list / tasks-claim 补可重复领取/逐次减半/409 语义。 - 测试: 新增「领满到底→409 + claimed=True」用例;test_welfare 全绿(3 个 proxy 失败 为 pre-existing httpx mock 问题,与本次无关)。 注: review 标的「do_signin 未归一化老 cycle_day」经验算为非 bug(x%LEN+1 对越界值 本就周期正确,且与 get_status 预览公式恒等),未改。 含本分支签到7天改制/任务文案/膨胀金币等 WIP。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
203 lines
9.3 KiB
Python
203 lines
9.3 KiB
Python
"""钱包相关表:金币账户 + 金币流水。
|
|
|
|
设计要点:
|
|
- 余额快照(coin_account)用于读取,流水账本(coin_transaction)用于对账,
|
|
每次余额变动都写一笔流水并记 balance_after,出问题能逐笔回溯。
|
|
- 金额一律存整数:金币是个数,现金存"分"(cash_balance_cents),不用浮点。
|
|
- cash_balance_cents 本步先留列、恒为 0,金币兑现金(第 2 步)再启用。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
|
|
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, func, text
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from app.db.base import Base
|
|
|
|
|
|
class CoinAccount(Base):
|
|
__tablename__ = "coin_account"
|
|
|
|
# 一个用户一行,user_id 既是主键也是外键
|
|
user_id: Mapped[int] = mapped_column(
|
|
Integer, ForeignKey("user.id"), primary_key=True
|
|
)
|
|
coin_balance: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
cash_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
# 累计赚取的金币(只增不减),用于"历史总收益"类展示
|
|
total_coin_earned: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
server_default=func.now(),
|
|
onupdate=func.now(),
|
|
nullable=False,
|
|
)
|
|
|
|
def __repr__(self) -> str: # pragma: no cover
|
|
return f"<CoinAccount user_id={self.user_id} coin={self.coin_balance}>"
|
|
|
|
|
|
class CoinTransaction(Base):
|
|
__tablename__ = "coin_transaction"
|
|
__table_args__ = (
|
|
# 可重复任务(如 task_enable_notification)按 ref_id 序号(task_key:N)去重,挡并发/连点
|
|
# 重复发放——读-算-写无锁,两个并发 claim 会都算出同一序号双倍发钱(见 task.claim_task)。
|
|
# 仅 task_* 且 ref_id 非空生效;一次性任务 ref_id=task_key 本就每人一条,纳入无害;
|
|
# 不影响 signin / exchange / withdraw 等其它 biz_type。
|
|
Index(
|
|
"ux_coin_transaction_task_ref",
|
|
"user_id",
|
|
"biz_type",
|
|
"ref_id",
|
|
unique=True,
|
|
sqlite_where=text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"),
|
|
postgresql_where=text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"),
|
|
),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
user_id: Mapped[int] = mapped_column(
|
|
Integer, ForeignKey("user.id"), index=True, nullable=False
|
|
)
|
|
# 正数=入账(赚),负数=出账(花/兑换)
|
|
amount: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
# 本笔变动后的金币余额,对账用
|
|
balance_after: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
# 业务类型:signin / task_<key> / exchange_out / ...
|
|
biz_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
# 关联业务 id(签到日期、任务 key 等),可空
|
|
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"<CoinTransaction id={self.id} user_id={self.user_id} amount={self.amount}>"
|
|
|
|
|
|
class WithdrawOrder(Base):
|
|
"""提现单(现金 → 微信零钱)。状态机:
|
|
reviewing →(管理员通过)→ pending → success / failed
|
|
reviewing →(管理员拒绝)→ rejected(已退款)
|
|
|
|
扣现金 + 写 cash_transaction(withdraw) 与建本单在同一事务,初始 reviewing(待人工审核,
|
|
**不打款**);管理员审核通过才调微信转账(execute_withdraw_transfer)→ pending,拒绝则退回
|
|
现金 + 置 rejected。微信侧失败/取消时退回现金并写 cash_transaction(withdraw_refund) → failed。
|
|
wechat_state 存微信侧原始状态(WAIT_USER_CONFIRM / SUCCESS / FAIL / CANCELLED ...)。
|
|
"""
|
|
|
|
__tablename__ = "withdraw_order"
|
|
__table_args__ = (
|
|
Index(
|
|
"ux_withdraw_order_user_active",
|
|
"user_id",
|
|
unique=True,
|
|
sqlite_where=text("status IN ('reviewing', 'pending')"),
|
|
postgresql_where=text("status IN ('reviewing', 'pending')"),
|
|
),
|
|
)
|
|
|
|
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
|
|
)
|
|
# 商户单号(我们生成,微信查单的 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)
|
|
# 提现实名(微信达额转账要求):审核后异步打款时要用,发起提现时存下,可空
|
|
user_name: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
# 归一化状态:reviewing(待审核) / pending(打款在途) / success / failed(打款失败已退) / rejected(审核拒绝已退)
|
|
status: Mapped[str] = mapped_column(String(16), nullable=False, default="reviewing")
|
|
# 微信侧原始状态
|
|
wechat_state: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
|
# 微信转账单号
|
|
transfer_bill_no: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
# 待用户确认时返回给 App 拉起确认页的 package_info
|
|
package_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
|
fail_reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
|
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
|
)
|
|
|
|
def __repr__(self) -> str: # pragma: no cover
|
|
return f"<WithdrawOrder id={self.id} user_id={self.user_id} cents={self.amount_cents} {self.status}>"
|
|
|
|
|
|
class WechatTransferAuthorization(Base):
|
|
"""微信商家转账「免确认收款授权」(用户授权免确认模式)。一个用户一条(user_id 主键)。
|
|
|
|
状态机:
|
|
(无) →(申请 / 首单转账顺带申请)→ pending(微信 WAIT_USER_CONFIRM,待用户在微信确认授权)
|
|
pending →(用户确认授权)→ active(微信 TAKING_EFFECT,此后转账免用户逐笔确认)
|
|
pending / active →(用户在微信关 / 商户解除 / 风控)→ closed(终态,需重新开启)
|
|
out_authorization_no 我方生成(查授权 / 发起授权用,一个用户一条稳定值,重开时换新);
|
|
authorization_id 微信在 active 后返回,免确认转账(transfer_with_authorization)时必传。
|
|
"""
|
|
|
|
__tablename__ = "wechat_transfer_authorization"
|
|
|
|
user_id: Mapped[int] = mapped_column(
|
|
Integer, ForeignKey("user.id"), primary_key=True
|
|
)
|
|
openid: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
# 商户侧授权单号(我方生成),唯一
|
|
out_authorization_no: Mapped[str] = mapped_column(
|
|
String(64), unique=True, index=True, nullable=False
|
|
)
|
|
# 微信侧授权单号(active 后返回,免确认转账要用)
|
|
authorization_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
# pending(待用户确认) / active(已生效可免确认) / closed(已关闭需重开)
|
|
state: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
|
|
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
|
)
|
|
|
|
def __repr__(self) -> str: # pragma: no cover
|
|
return f"<WechatTransferAuthorization user_id={self.user_id} state={self.state}>"
|
|
|
|
|
|
class CashTransaction(Base):
|
|
"""现金流水(单位:分)。金币兑现金、提现都记在这里。"""
|
|
|
|
__tablename__ = "cash_transaction"
|
|
__table_args__ = (
|
|
Index(
|
|
"ux_cash_transaction_withdraw_refund_ref",
|
|
"ref_id",
|
|
unique=True,
|
|
sqlite_where=text("biz_type = 'withdraw_refund' AND ref_id IS NOT NULL"),
|
|
postgresql_where=text("biz_type = '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)
|
|
# 业务类型:exchange_in / withdraw
|
|
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"<CashTransaction id={self.id} user_id={self.user_id} cents={self.amount_cents}>"
|