feat(invite): 邀请奖励金账户(与金币隔离) + 比价发奖 + 奖励金提现 (#82)
Reviewed-on: #82 Co-authored-by: xiebing <xiebing@wonderable.ai> Co-committed-by: xiebing <xiebing@wonderable.ai>
This commit was merged in pull request #82.
This commit is contained in:
@@ -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')
|
||||
@@ -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,
|
||||
|
||||
@@ -38,7 +38,7 @@ logger = logging.getLogger("shagua.invite")
|
||||
router = APIRouter(prefix="/api/v1/invite", tags=["invite"])
|
||||
|
||||
_BIND_MESSAGES = {
|
||||
"success": "邀请绑定成功,金币已到账",
|
||||
"success": "邀请绑定成功",
|
||||
"already_bound": "你已绑定过邀请人",
|
||||
"invalid_code": "邀请码无效",
|
||||
"self_invite": "不能填写自己的邀请码",
|
||||
@@ -84,6 +84,8 @@ def _parse_device_model(ua: str) -> str:
|
||||
def my_invite(user: CurrentUser, db: DbSession) -> InviteInfoOut:
|
||||
code = invite_repo.ensure_code(db, user)
|
||||
invited, coins = invite_repo.get_stats(db, user.id)
|
||||
reward_balance, reward_withdrawn = invite_repo.get_reward_stats(db, user.id)
|
||||
days_left, is_fresh_round, countdown_text = invite_repo.compute_invite_countdown(user.created_at)
|
||||
sep = "&" if "?" in settings.INVITE_LANDING_URL else "?"
|
||||
share_url = f"{settings.INVITE_LANDING_URL}{sep}ref={code}"
|
||||
return InviteInfoOut(
|
||||
@@ -91,6 +93,11 @@ def my_invite(user: CurrentUser, db: DbSession) -> InviteInfoOut:
|
||||
share_url=share_url,
|
||||
invited_count=invited,
|
||||
coins_earned=coins,
|
||||
reward_balance_cents=reward_balance,
|
||||
reward_withdrawn_cents=reward_withdrawn,
|
||||
countdown_days_left=days_left,
|
||||
countdown_is_fresh_round=is_fresh_round,
|
||||
countdown_text=countdown_text,
|
||||
)
|
||||
|
||||
|
||||
@@ -154,7 +161,7 @@ def landing_track(
|
||||
return LandingTrackOut(status="ok")
|
||||
|
||||
|
||||
@router.post("/bind", response_model=BindInviteOut, summary="绑定邀请人(注册即生效,双方发金币)")
|
||||
@router.post("/bind", response_model=BindInviteOut, summary="绑定邀请人(注册即生效,绑定不发奖)")
|
||||
def bind_invite(
|
||||
req: BindInviteIn, user: CurrentUser, db: DbSession, request: Request
|
||||
) -> BindInviteOut:
|
||||
|
||||
@@ -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,
|
||||
|
||||
+10
-4
@@ -113,10 +113,9 @@ PRICE_REPORT_REWARD_COINS: int = 1000
|
||||
FEEDBACK_REWARD_MAX_COINS: int = 10000
|
||||
|
||||
|
||||
# ===== 邀请好友(注册即生效,邀请人 + 被邀请人各发金币)=====
|
||||
# 10000 金币 = 1 元,双方各得 1 元。MVP 先用固定常量(不走 app_config)。
|
||||
INVITE_INVITER_COINS: int = 10000
|
||||
INVITE_INVITEE_COINS: int = 10000
|
||||
# ===== 邀请好友(绑定即生效,绑定只建归因关系、双方都不发钱)=====
|
||||
# v3(冰 2026-06-26):废除 v1 的"绑定双方各发金币"——被邀请人无奖励、邀请人改比价后发现金
|
||||
# (见下方 INVITE_COMPARE_REWARD_CENTS)。原 INVITE_INVITER_COINS / INVITE_INVITEE_COINS 已删。
|
||||
# "新用户闸":被邀请人必须在注册后此窗口内绑定才发奖(挡存量老用户互相填码薅羊毛)。
|
||||
# 自动绑(剪贴板)在首次注册登录后几秒内发生;留 72h 给手动填码兜底。
|
||||
INVITE_NEW_USER_WINDOW_HOURS: int = 72
|
||||
@@ -127,6 +126,13 @@ INVITE_NEW_USER_WINDOW_HOURS: int = 72
|
||||
INVITE_FP_WINDOW_DAYS: int = 7
|
||||
|
||||
|
||||
# ===== 邀请好友 v2(好友"下载+登录+比价一次"→ 给邀请人发邀请奖励金·现金)=====
|
||||
# v2 新规则:不再注册即发金币,改"好友完成首次成功比价"才给【邀请人】发奖,发的是【现金·分】
|
||||
# 进独立的邀请奖励金账户(coin_account.invite_cash_balance_cents),与金币体系物理隔离。
|
||||
# 200 分 = 2 元。⚠️ 金额待产品定准:邀请主页=2元 / 福利入口=3.5元 不一致,定后改此处。
|
||||
INVITE_COMPARE_REWARD_CENTS: int = 200
|
||||
|
||||
|
||||
# ===== 看激励视频 / 信息流广告发金币 =====
|
||||
# eCPM 取自穿山甲 SDK getShowEcpm().getEcpm(),官方口径单位是【分/千次展示】(不是元!
|
||||
# csjplatform 文档原文"通过 getEcpm 获取的单位是分")。计算时先 ÷100 转成元;
|
||||
|
||||
@@ -37,5 +37,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}>"
|
||||
|
||||
+111
-27
@@ -1,13 +1,14 @@
|
||||
"""好友邀请 CRUD(注册即生效,邀请人 + 被邀请人各发金币)。
|
||||
"""好友邀请 CRUD(注册即生效,绑定只建归因关系、双方都不发钱)。
|
||||
|
||||
发奖规则(v3,冰 2026-06-26 拍板):
|
||||
- 绑定时双方都不发钱(被邀请人无奖励、邀请人不发金币)。
|
||||
- 邀请人的钱由 try_reward_on_compare 在好友"成功比价一次"后发 2 元邀请奖励金(防刷)。
|
||||
- v1 的"绑定双方各发金币"已废;invite_relation 的 inviter_coin/invitee_coin 列保留恒 0(待清)。
|
||||
|
||||
防重复发奖三道(仿 ad_reward / 提现的资金安全思路):
|
||||
1. invitee_user_id 唯一 → 一个被邀请人只能被绑定一次(幂等键)。
|
||||
2. 自邀屏蔽 → inviter == invitee 直接拒。
|
||||
3. 现成的手机号唯一(每个被邀请人 = 一个真实手机号账号)= 天然限制刷量规模。
|
||||
|
||||
发金币复用 wallet.grant_coins(grant 只 flush 不 commit),与建关系记录在**同一事务**
|
||||
commit,保证"建关系 + 双方加金币"原子。奖励额 = rewards.INVITE_INVITER_COINS /
|
||||
INVITE_INVITEE_COINS。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -34,6 +35,30 @@ def _gen_code() -> str:
|
||||
return "".join(secrets.choice(_CODE_ALPHABET) for _ in range(_CODE_LEN))
|
||||
|
||||
|
||||
# ===== v2 邀请倒计时(7 天 1 轮,锚点=注册日,自然日差,东八区)=====
|
||||
# 中国不用夏令时,固定 +8 偏移即可(不依赖 tzdata,Windows 本地联调也稳)。
|
||||
_CST = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def compute_invite_countdown(register_at: datetime) -> tuple[int, bool, str]:
|
||||
"""按 7 天 1 轮算邀请页倒计时。
|
||||
|
||||
锚点 = 用户注册日(user.created_at),按东八区自然日差算
|
||||
(跨自然日才减、同日多次登录不减,天然满足)。返回:
|
||||
(本轮剩余天数 1..7, 是否刚进入新一轮[非首轮第1天], 展示文案)。
|
||||
"""
|
||||
reg = register_at if register_at.tzinfo else register_at.replace(tzinfo=timezone.utc)
|
||||
days_since = max(0, (datetime.now(_CST).date() - reg.astimezone(_CST).date()).days)
|
||||
day_in_cycle = days_since % 7 # 0..6(本轮第几天,0-based)
|
||||
days_left = 7 - day_in_cycle # 7..1(第1天=7、第7天=1)
|
||||
is_fresh_round = days_since >= 7 and day_in_cycle == 0 # 非首轮的第1天
|
||||
if is_fresh_round:
|
||||
text = "恭喜您进入新一轮邀请!\n距离本轮结束还有7天"
|
||||
else:
|
||||
text = f"距本轮结束还有{days_left}天"
|
||||
return days_left, is_fresh_round, text
|
||||
|
||||
|
||||
def ensure_code(db: Session, user: User) -> str:
|
||||
"""保证 user 有邀请码(懒生成),返回它。唯一约束碰撞则换码重试。
|
||||
|
||||
@@ -90,15 +115,17 @@ def _is_new_user(user: User) -> bool:
|
||||
class BindResult:
|
||||
status: str # success / already_bound / invalid_code / self_invite / not_eligible
|
||||
relation: InviteRelation | None = None
|
||||
invitee_coin: int = 0 # 本次给被邀请人发的金币(success 时 >0)
|
||||
invitee_coin: int = 0 # v3 起恒 0(绑定不再发金币);保留字段兼容响应
|
||||
|
||||
|
||||
def bind(
|
||||
db: Session, *, invitee: User, invite_code: str, channel: str = "clipboard"
|
||||
) -> BindResult:
|
||||
"""把 invitee 绑定到 invite_code 对应的邀请人,注册即生效 + 双方发金币。
|
||||
"""把 invitee 绑定到 invite_code 对应的邀请人,注册即生效。
|
||||
|
||||
幂等:invitee 已被绑过 → already_bound(不重复发奖)。
|
||||
v3 发奖(冰 2026-06-26 拍板):绑定双方都不发钱——被邀请人无奖励,邀请人改"好友成功比价一次
|
||||
才发 2 元邀请奖励金"(见 try_reward_on_compare,防刷)。绑定只建归因关系 + 跑防刷闸。
|
||||
幂等:已绑过 → already_bound。
|
||||
"""
|
||||
# 幂等:已绑过直接返回(不重复发奖)
|
||||
existing = _relation_of_invitee(db, invitee.id)
|
||||
@@ -114,27 +141,18 @@ def bind(
|
||||
if not _is_new_user(invitee):
|
||||
return BindResult("not_eligible")
|
||||
|
||||
inviter_coin = rewards.INVITE_INVITER_COINS
|
||||
invitee_coin = rewards.INVITE_INVITEE_COINS
|
||||
|
||||
# v3(冰 2026-06-26 拍板):绑定双方都不发钱。被邀请人不再发新人金币(去掉拉新即时激励);
|
||||
# 邀请人的钱由 try_reward_on_compare 在好友成功比价后发 2 元邀请奖励金(防刷)。
|
||||
rel = InviteRelation(
|
||||
inviter_user_id=inviter.id,
|
||||
invitee_user_id=invitee.id,
|
||||
channel=(channel or "clipboard")[:16],
|
||||
status="effective",
|
||||
inviter_coin=inviter_coin,
|
||||
invitee_coin=invitee_coin,
|
||||
inviter_coin=0, # v1 金币线已停用,列保留恒 0(待清)
|
||||
invitee_coin=0, # v3:被邀请人绑定不再发金币
|
||||
)
|
||||
db.add(rel)
|
||||
# 双方发金币(同事务,与建关系一起 commit)。ref_id 互指对方便于对账。
|
||||
crud_wallet.grant_coins(
|
||||
db, inviter.id, inviter_coin,
|
||||
biz_type="invite_inviter", ref_id=str(invitee.id), remark="邀请好友奖励",
|
||||
)
|
||||
crud_wallet.grant_coins(
|
||||
db, invitee.id, invitee_coin,
|
||||
biz_type="invite_invitee", ref_id=str(inviter.id), remark="新人受邀奖励",
|
||||
)
|
||||
# 绑定不发任何金币(邀请人改比价发现金、被邀请人无奖励),仅建归因关系。
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
@@ -145,16 +163,62 @@ def bind(
|
||||
return BindResult("already_bound", existing)
|
||||
raise
|
||||
except Exception:
|
||||
# 其它 commit 失败(DB 故障 / PG 序列化冲突等):显式回滚,保证"建关系 + 双方发币"
|
||||
# 原子(要么全成要么全无),不依赖 get_db 关闭时的隐式回滚,语义更硬。
|
||||
# 其它 commit 失败(DB 故障 / PG 序列化冲突等):显式回滚,保证建关系原子,
|
||||
# 不依赖 get_db 关闭时的隐式回滚,语义更硬。
|
||||
db.rollback()
|
||||
raise
|
||||
db.refresh(rel)
|
||||
return BindResult("success", rel, invitee_coin)
|
||||
return BindResult("success", rel)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompareRewardResult:
|
||||
status: str # granted / no_relation / already_granted / inviter_inactive
|
||||
inviter_user_id: int | None = None
|
||||
reward_cents: int = 0
|
||||
|
||||
|
||||
def try_reward_on_compare(db: Session, invitee_user_id: int) -> CompareRewardResult:
|
||||
"""被邀请人完成一次成功比价时调用:若其有邀请关系且尚未发过比价奖,给【邀请人】发邀请奖励金。
|
||||
|
||||
v2 发奖规则核心(替代 v1 的"注册即发金币"):好友"下载+登录+比价一次"→ 邀请人得 2 元现金。
|
||||
幂等:compare_reward_granted 标记保证好友比价多次只发一次。无邀请关系 / 已发过 / 邀请人失效
|
||||
→ 空操作。发奖(grant_invite_cash 入账独立账户)+ 置标记同事务 commit,保证原子。
|
||||
"""
|
||||
rel = _relation_of_invitee(db, invitee_user_id)
|
||||
if rel is None:
|
||||
return CompareRewardResult("no_relation")
|
||||
if rel.compare_reward_granted:
|
||||
return CompareRewardResult("already_granted", rel.inviter_user_id)
|
||||
|
||||
inviter = db.get(User, rel.inviter_user_id)
|
||||
if inviter is None or inviter.status != "active":
|
||||
# 邀请人注销 / 封禁:本次不发、不置标记,待其恢复后下次比价再试(保守,不吞奖励)
|
||||
return CompareRewardResult("inviter_inactive", rel.inviter_user_id)
|
||||
|
||||
reward = rewards.INVITE_COMPARE_REWARD_CENTS
|
||||
rel.compare_reward_granted = True
|
||||
rel.compare_reward_cents = reward
|
||||
rel.compare_rewarded_at = datetime.now(timezone.utc)
|
||||
# 发邀请奖励金到邀请人的独立账户(与金币隔离),ref_id 指向被邀请人便于对账
|
||||
crud_wallet.grant_invite_cash(
|
||||
db, inviter.id, reward,
|
||||
biz_type="invite_reward", ref_id=str(invitee_user_id), remark="好友比价奖励",
|
||||
)
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
return CompareRewardResult("granted", inviter.id, reward)
|
||||
|
||||
|
||||
def get_stats(db: Session, inviter_id: int) -> tuple[int, int]:
|
||||
"""返回 (已成功邀请人数, 累计从邀请获得的金币)。"""
|
||||
"""返回 (已成功邀请人数, 累计从邀请获得的金币)。
|
||||
|
||||
金币口径(inviter_coin 之和)自 v3 起恒 0(邀请人收益改走邀请奖励金,见 get_reward_stats /
|
||||
try_reward_on_compare);保留返回位兼容旧响应字段 coins_earned。
|
||||
"""
|
||||
count = db.execute(
|
||||
select(func.count())
|
||||
.select_from(InviteRelation)
|
||||
@@ -167,6 +231,26 @@ def get_stats(db: Session, inviter_id: int) -> tuple[int, int]:
|
||||
return int(count), int(coins)
|
||||
|
||||
|
||||
def get_reward_stats(db: Session, inviter_id: int) -> tuple[int, int]:
|
||||
"""v2 邀请奖励金战绩:(可提现余额/分, 累计提现成功/分)。
|
||||
|
||||
余额 = coin_account.invite_cash_balance_cents;累计提现 = 该用户 source=invite_cash 且
|
||||
status=success 的提现单金额之和(成功打款才算)。供 /invite/me 提现板块展示。
|
||||
"""
|
||||
from app.models.wallet import CoinAccount, WithdrawOrder
|
||||
balance = db.execute(
|
||||
select(CoinAccount.invite_cash_balance_cents).where(CoinAccount.user_id == inviter_id)
|
||||
).scalar_one_or_none()
|
||||
withdrawn = db.execute(
|
||||
select(func.coalesce(func.sum(WithdrawOrder.amount_cents), 0)).where(
|
||||
WithdrawOrder.user_id == inviter_id,
|
||||
WithdrawOrder.source == "invite_cash",
|
||||
WithdrawOrder.status == "success",
|
||||
)
|
||||
).scalar_one()
|
||||
return int(balance or 0), int(withdrawn)
|
||||
|
||||
|
||||
def _mask_phone(phone: str) -> str:
|
||||
"""手机号脱敏:138****8888。前端拿不到完整号,展示被邀请人时在此兜底名字。
|
||||
|
||||
@@ -210,7 +294,7 @@ def get_invitees(
|
||||
items.append({
|
||||
"display_name": u.nickname or u.wechat_nickname or _mask_phone(u.phone),
|
||||
"avatar_url": u.avatar_url or u.wechat_avatar_url or None,
|
||||
"coins": rel.inviter_coin,
|
||||
"coins": rel.inviter_coin, # v3 起恒 0(邀请人收益改走邀请奖励金)
|
||||
"invited_at": rel.created_at,
|
||||
})
|
||||
has_more = offset + len(rows) < int(total)
|
||||
|
||||
+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
|
||||
|
||||
+224
-117
@@ -8,122 +8,231 @@
|
||||
<style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
|
||||
html,body { height:100%; }
|
||||
button { border:0; background:none; color:inherit; font:inherit; cursor:pointer; }
|
||||
body {
|
||||
font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;
|
||||
background:linear-gradient(165deg,#FF7A3D 0%,#FF3B30 52%,#E0245E 100%);
|
||||
color:#fff; min-height:100%; display:flex; flex-direction:column;
|
||||
align-items:center; justify-content:center; padding:40px 26px; text-align:center;
|
||||
overflow-x:hidden;
|
||||
min-height:100vh;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
background:#000;
|
||||
font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Helvetica Neue",sans-serif;
|
||||
}
|
||||
.logo {
|
||||
width:104px; height:104px; border-radius:26px; background:#fff;
|
||||
display:flex; align-items:center; justify-content:center; font-size:52px;
|
||||
box-shadow:0 14px 34px rgba(0,0,0,.22); margin-bottom:24px;
|
||||
/* 设备框:桌面预览成 375×667 卡片;真机(≤430)铺满全屏 */
|
||||
.device {
|
||||
position:relative; flex:0 0 auto;
|
||||
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; }
|
||||
.slogan { margin-top:12px; font-size:16px; line-height:1.7; opacity:.95; max-width:300px; }
|
||||
.feats { margin-top:26px; display:flex; flex-direction:column; gap:12px; width:100%; max-width:320px; }
|
||||
.feat { background:rgba(255,255,255,.16); border-radius:14px; padding:13px 16px; font-size:15px; display:flex; align-items:center; gap:10px; }
|
||||
.feat b { font-weight:700; }
|
||||
.btn {
|
||||
margin-top:34px; width:100%; max-width:320px; border:none; cursor:pointer;
|
||||
background:#fff; color:#FF3B30; font-size:19px; font-weight:800;
|
||||
padding:17px 0; border-radius:999px; box-shadow:0 10px 26px rgba(0,0,0,.22);
|
||||
display:flex; align-items:center; justify-content:center; gap:9px;
|
||||
.download-landing {
|
||||
position:absolute; inset:0; z-index:0; overflow:hidden;
|
||||
background:
|
||||
url('coupon-page-bg.png') center center / cover no-repeat,
|
||||
#FFF4CD;
|
||||
color:#1A1A1A; text-align:center;
|
||||
}
|
||||
.download-content {
|
||||
position:relative; z-index:1; height:100%;
|
||||
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 {
|
||||
display:none; position:fixed; inset:0; z-index:9999;
|
||||
background:rgba(0,0,0,.86); padding:18px;
|
||||
.download-guide-layer {
|
||||
position:absolute; inset:0; z-index:30; display:none;
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
<div class="logo">🛒</div>
|
||||
<h1>傻瓜比价</h1>
|
||||
<div class="slogan">买什么都先比一比<br>自动帮你找全网最低价</div>
|
||||
|
||||
<div class="feats">
|
||||
<div class="feat">🍔 <span>点外卖前一键比价,<b>美团/京东/淘宝</b>到手价一目了然</span></div>
|
||||
<div class="feat">🎟️ <span>自动领遍各平台<b>红包券</b>,能省的一分不漏</span></div>
|
||||
<div class="feat">💰 <span>省下的钱看得见,还能<b>赚金币提现</b></span></div>
|
||||
</div>
|
||||
|
||||
<button class="btn" id="dlbtn">🏪 打开应用商店下载</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>
|
||||
<main class="device" aria-label="傻瓜比价下载页">
|
||||
<section class="download-landing" aria-label="傻瓜比价下载页">
|
||||
<div class="download-content">
|
||||
<div class="brand-lockup">
|
||||
<img class="ad-logo" src="sb-brand.png" alt="傻瓜比价">
|
||||
<h1 class="ad-title">傻瓜比价</h1>
|
||||
</div>
|
||||
<p class="ad-subtitle">跨平台比价,用傻瓜</p>
|
||||
<div class="bottom-area" aria-label="下载入口">
|
||||
<button class="download-btn primary" id="dlbtn" type="button">应用商店下载</button>
|
||||
<button class="download-btn secondary" id="dlbtn2" type="button">官网下载</button>
|
||||
</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 class="toast" id="toast" role="status" aria-live="polite"></div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// 应用商店跳转:用包名(App 唯一标识 = 身份证号)定位到傻瓜比价的商店下载页。
|
||||
// 主:market:// 唤起手机自带应用市场(华为/小米/OV);兜底:应用宝网页(任何浏览器都能开)。
|
||||
// ===== 应用商店跳转:用包名(App 唯一标识)定位到傻瓜比价的商店下载页 =====
|
||||
// 主:market:// 唤起手机自带应用市场(华为/小米/OV);兜底:应用宝网页(任何浏览器都能开)。
|
||||
var PKG = "com.jishisongfu.shaguabijia";
|
||||
var MARKET_URL = "market://details?id=" + PKG;
|
||||
var YYB_URL = "https://a.app.qq.com/o/simple.jsp?pkgname=" + PKG;
|
||||
var ua = navigator.userAgent || "";
|
||||
var isWeChat = /MicroMessenger/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 表。
|
||||
// 当 APK 首启读剪贴板失败(被覆盖)时,客户端会用 (IP+屏幕+UA 解析的手机型号) 反查
|
||||
// 本表 7 天内最近一条匹配 → 撞库出原邀请人 → 走原 bind 流程。
|
||||
// 任何失败都 silent(不影响下载主流程);后端 invalid_code/no_ip 也只返 200。
|
||||
// ===== 指纹归因兜底:页面加载即上报访问者指纹,后端存 invite_fingerprint 表 =====
|
||||
// 当 APK 首启读剪贴板失败(被覆盖)时,客户端用 (IP+屏幕+UA 机型) 反查 7 天内最近一条 → 撞出原邀请人。
|
||||
// 任何失败都 silent,不影响下载主流程。
|
||||
if (ref) {
|
||||
// screen 报【物理像素】= CSS 像素 × devicePixelRatio,跟 Android dm.widthPixels(物理像素)对齐。
|
||||
// 不同设备 DPR 不同(常见 2/2.5/3/3.5),CSS 像素直接报会跟客户端不对齐 → 撞不上库。
|
||||
// screen 报【物理像素】= CSS 像素 × devicePixelRatio,跟 Android dm.widthPixels 对齐,否则撞不上库。
|
||||
var _dpr = window.devicePixelRatio || 1;
|
||||
var _sw = Math.round(screen.width * _dpr);
|
||||
var _sh = Math.round(screen.height * _dpr);
|
||||
fetch("/api/v1/invite/landing-track", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ref: ref,
|
||||
screen: _sw + "x" + _sh,
|
||||
// IP / UA 服务端从 HTTP 头自动拿,无需 JS 上报
|
||||
}),
|
||||
}).catch(function () {}); // silent,绝不阻断下载
|
||||
body: JSON.stringify({ ref: ref, screen: _sw + "x" + _sh }),
|
||||
}).catch(function () {}); // silent,绝不阻断下载
|
||||
}
|
||||
|
||||
// 把邀请码写进剪贴板,APK 首启时读出来完成归因(deferred deeplink 的关键一步)。
|
||||
// 浏览器要求:必须在用户点击手势里调用 + HTTPS 下才允许写。
|
||||
function legacyCopy(payload) { // 老 webview / 无 clipboard API 兜底
|
||||
// ===== 把邀请码写进剪贴板,APK 首启读出完成归因(deferred deeplink)=====
|
||||
// 浏览器要求:必须在用户点击手势里调用 + HTTPS 下才允许写。
|
||||
function legacyCopy(payload) { // 老 webview / 无 clipboard API 兜底
|
||||
try {
|
||||
var ta = document.createElement("textarea");
|
||||
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);
|
||||
} catch (e) {}
|
||||
}
|
||||
function copyInviteCode() { // 返回 Promise(完成后才下载,避免异步写入被打断)
|
||||
function copyInviteCode() { // 返回 Promise,完成后才下载,避免异步写入被打断
|
||||
if (!ref) return Promise.resolve();
|
||||
var payload = "SGBJ_INVITE:" + ref;
|
||||
legacyCopy(payload); // 同步兜底:在用户手势内立刻 execCommand 写一次(最可靠)
|
||||
legacyCopy(payload); // 同步兜底:在用户手势内立刻 execCommand 写一次(最可靠)
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
return navigator.clipboard.writeText(payload).catch(function () {}); // 现代 API 锦上添花,失败无妨
|
||||
return navigator.clipboard.writeText(payload).catch(function () {});
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
// 跳应用商店:先尝试 market:// 唤起手机自带商店;若 2.5s 内页面没切到后台
|
||||
//(= 没有商店接管 market://),兜底跳应用宝网页下载页,不让用户卡死。
|
||||
// 跳应用商店:先 market:// 唤起自带商店;2.5s 内页面没切后台 → 兜底跳应用宝网页,不让用户卡死。
|
||||
function openStore() {
|
||||
var jumped = false;
|
||||
function markJumped() { jumped = true; } // 页面切到后台 = 商店已唤起
|
||||
document.addEventListener("visibilitychange", function () {
|
||||
if (document.hidden) jumped = true;
|
||||
});
|
||||
window.addEventListener("pagehide", markJumped);
|
||||
window.addEventListener("blur", markJumped);
|
||||
window.location.href = MARKET_URL; // 唤起自带应用市场
|
||||
setTimeout(function () {
|
||||
if (!jumped) window.location.href = YYB_URL; // 没唤起 → 应用宝网页兜底
|
||||
}, 2500);
|
||||
document.addEventListener("visibilitychange", function () { if (document.hidden) jumped = true; });
|
||||
window.addEventListener("pagehide", function () { jumped = true; });
|
||||
window.addEventListener("blur", function () { jumped = true; });
|
||||
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 showMask(){ mask.classList.add("show"); }
|
||||
function hideMask(){ mask.classList.remove("show"); }
|
||||
document.getElementById("wxclose").addEventListener("click", hideMask);
|
||||
function showToast(text) {
|
||||
var toast = document.getElementById("toast");
|
||||
toast.textContent = text; toast.classList.add("show");
|
||||
clearTimeout(showToast.timer);
|
||||
showToast.timer = setTimeout(function () { toast.classList.remove("show"); }, 1400);
|
||||
}
|
||||
|
||||
// 微信里一进页面就提示去浏览器打开(下载在微信内必被拦)
|
||||
if (isWeChat) showMask();
|
||||
|
||||
document.getElementById("dlbtn").addEventListener("click", function(){
|
||||
if (isWeChat) { showMask(); return; } // 微信内:引导去浏览器
|
||||
if (isIOS) { alert("iOS 版即将上线,请前往 App Store 搜索「傻瓜比价」"); return; }
|
||||
// 安卓浏览器:先把邀请码写进剪贴板(legacyCopy 同步写、最可靠),再跳应用商店。
|
||||
// 剪贴板供 App 首启归因;链路长易丢时由指纹兜底(已上报 landing-track)接住。
|
||||
copyInviteCode();
|
||||
// ===== 下载按钮:微信内引导去浏览器;iOS 提示;安卓写邀请码 + 跳应用商店 =====
|
||||
function handleDownload() {
|
||||
if (isWeChat) { showWxGuide(); return; }
|
||||
if (isIOS) { alert("iOS 版即将上线,请前往 App Store 搜索「傻瓜比价」"); return; }
|
||||
copyInviteCode(); // 先把邀请码写进剪贴板(供 App 首启归因),链路丢了还有 landing-track 指纹兜底
|
||||
openStore();
|
||||
});
|
||||
}
|
||||
document.getElementById("dlbtn").addEventListener("click", handleDownload);
|
||||
document.getElementById("dlbtn2").addEventListener("click", handleDownload);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+14
-15
@@ -1,4 +1,4 @@
|
||||
"""好友邀请测试:邀请码、绑定、双方发金币、幂等、自邀/无效码屏蔽、指纹兜底归因。
|
||||
"""好友邀请测试:邀请码、绑定(双方不发钱)、幂等、自邀/无效码屏蔽、指纹兜底归因。
|
||||
|
||||
用 sms mock 登录拿 token(同 test_welfare),再跑邀请闭环。
|
||||
"""
|
||||
@@ -10,8 +10,6 @@ from sqlalchemy import select
|
||||
|
||||
from app.core.rewards import (
|
||||
INVITE_FP_WINDOW_DAYS,
|
||||
INVITE_INVITEE_COINS,
|
||||
INVITE_INVITER_COINS,
|
||||
INVITE_NEW_USER_WINDOW_HOURS,
|
||||
)
|
||||
from app.db.session import SessionLocal
|
||||
@@ -56,8 +54,8 @@ def test_invite_me_returns_stable_code(client) -> None:
|
||||
assert _my_code(client, token) == body["invite_code"]
|
||||
|
||||
|
||||
def test_bind_flow_both_get_coins(client) -> None:
|
||||
"""B 用 A 的码绑定 → 双方各得 1 万金币;A 战绩 +1。"""
|
||||
def test_bind_flow_no_coins_either_side(client) -> None:
|
||||
"""v3:B 用 A 的码绑定 → 双方都不发钱(被邀请人无奖励、邀请人改比价后发现金)。"""
|
||||
a = _login(client, "13800002002")
|
||||
b = _login(client, "13800002003")
|
||||
a_code = _my_code(client, a)
|
||||
@@ -69,16 +67,16 @@ def test_bind_flow_both_get_coins(client) -> None:
|
||||
assert r.status_code == 200, r.text
|
||||
res = r.json()
|
||||
assert res["status"] == "success"
|
||||
assert res["coins_awarded"] == INVITE_INVITEE_COINS
|
||||
assert res["coins_awarded"] == 0
|
||||
|
||||
# 双方金币到账
|
||||
assert _coin_balance(client, b) == INVITE_INVITEE_COINS
|
||||
assert _coin_balance(client, a) == INVITE_INVITER_COINS
|
||||
# 绑定后双方金币都不增(邀请人收益改走"好友比价发 2 元邀请奖励金")
|
||||
assert _coin_balance(client, b) == 0
|
||||
assert _coin_balance(client, a) == 0
|
||||
|
||||
# A 的战绩:已邀 1 人,累计获得 = 邀请人那份
|
||||
# A 的战绩:已邀 1 人;金币口径收益恒 0(邀请人收益走邀请奖励金,见 try_reward_on_compare)
|
||||
r = client.get("/api/v1/invite/me", headers=_auth(a))
|
||||
assert r.json()["invited_count"] == 1
|
||||
assert r.json()["coins_earned"] == INVITE_INVITER_COINS
|
||||
assert r.json()["coins_earned"] == 0
|
||||
|
||||
|
||||
def test_bind_idempotent_no_double_reward(client) -> None:
|
||||
@@ -217,9 +215,9 @@ def test_bind_by_fingerprint_success(client) -> None:
|
||||
)
|
||||
assert r2.status_code == 200, r2.text
|
||||
assert r2.json()["status"] == "success"
|
||||
# 双方各发金币
|
||||
assert _coin_balance(client, a) == INVITE_INVITER_COINS
|
||||
assert _coin_balance(client, b) == INVITE_INVITEE_COINS
|
||||
# v3:绑定双方都不发钱(被邀请人无奖励、邀请人改比价发现金)
|
||||
assert _coin_balance(client, a) == 0
|
||||
assert _coin_balance(client, b) == 0
|
||||
|
||||
|
||||
def test_bind_by_fingerprint_not_found(client) -> None:
|
||||
@@ -322,7 +320,8 @@ def test_invitees_basic(client) -> None:
|
||||
names = {it["display_name"] for it in body["items"]}
|
||||
assert names == expected_names
|
||||
assert all(it["avatar_url"] is None for it in body["items"])
|
||||
assert all(it["coins"] == INVITE_INVITER_COINS for it in body["items"])
|
||||
# v2:绑定时邀请人那份为 0(收益改"好友比价才发奖"),故每条 coins 字段=0
|
||||
assert all(it["coins"] == 0 for it in body["items"])
|
||||
|
||||
|
||||
def test_invitees_order_desc(client) -> None:
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,114 @@
|
||||
"""邀请 v3 比价发奖测试:好友完成成功比价 → 邀请人得邀请奖励金(独立账户),幂等只发一次,
|
||||
账户隔离不串金币/现金。被邀请人绑定不得任何奖励由 test_invite.py 覆盖。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.rewards import INVITE_COMPARE_REWARD_CENTS
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.wallet import CoinAccount, InviteCashTransaction
|
||||
from app.repositories.user import get_user_by_phone
|
||||
|
||||
|
||||
def _login(client, phone: str) -> str:
|
||||
client.post("/api/v1/auth/sms/send", json={"phone": phone})
|
||||
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def _auth(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _my_code(client, token: str) -> str:
|
||||
return client.get("/api/v1/invite/me", headers=_auth(token)).json()["invite_code"]
|
||||
|
||||
|
||||
def _bind(client, token: str, code: str):
|
||||
return client.post("/api/v1/invite/bind", json={"invite_code": code}, headers=_auth(token))
|
||||
|
||||
|
||||
def _report_compare(client, token: str, trace_id: str, status: str = "success"):
|
||||
return client.post(
|
||||
"/api/v1/compare/record",
|
||||
json={"trace_id": trace_id, "status": status},
|
||||
headers=_auth(token),
|
||||
)
|
||||
|
||||
|
||||
def _invite_cash(phone: str) -> int:
|
||||
"""被试用户的邀请奖励金余额(分)。专用查询端点见小步3,这里直接查 DB。"""
|
||||
with SessionLocal() as db:
|
||||
u = get_user_by_phone(db, phone)
|
||||
acc = db.get(CoinAccount, u.id) if u else None
|
||||
return acc.invite_cash_balance_cents if acc else 0
|
||||
|
||||
|
||||
def test_compare_reward_granted_to_inviter(client) -> None:
|
||||
"""B 被 A 邀请后完成一次成功比价 → A 得邀请奖励金 2 元(独立账户),B 不得该奖。"""
|
||||
a = _login(client, "13800003001")
|
||||
b = _login(client, "13800003002")
|
||||
_bind(client, b, _my_code(client, a))
|
||||
|
||||
assert _invite_cash("13800003001") == 0 # 发奖前
|
||||
|
||||
r = _report_compare(client, b, "trace-reward-1")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
assert _invite_cash("13800003001") == INVITE_COMPARE_REWARD_CENTS # 邀请人到账
|
||||
assert _invite_cash("13800003002") == 0 # 被邀请人不得此奖
|
||||
|
||||
|
||||
def test_compare_reward_idempotent(client) -> None:
|
||||
"""好友比价多次 → 只发一次(compare_reward_granted 幂等)。"""
|
||||
a = _login(client, "13800003003")
|
||||
b = _login(client, "13800003004")
|
||||
_bind(client, b, _my_code(client, a))
|
||||
|
||||
_report_compare(client, b, "trace-idem-1")
|
||||
_report_compare(client, b, "trace-idem-2") # 第二次比价(不同 trace)
|
||||
_report_compare(client, b, "trace-idem-1") # 重复上报同 trace
|
||||
|
||||
assert _invite_cash("13800003003") == INVITE_COMPARE_REWARD_CENTS # 仍只发一次
|
||||
|
||||
|
||||
def test_compare_no_relation_no_reward(client) -> None:
|
||||
"""没有邀请关系的人比价 → 不发奖(没人是他的邀请人)。"""
|
||||
x = _login(client, "13800003005")
|
||||
r = _report_compare(client, x, "trace-norel-1")
|
||||
assert r.status_code == 200, r.text
|
||||
assert _invite_cash("13800003005") == 0
|
||||
|
||||
|
||||
def test_compare_failed_no_reward(client) -> None:
|
||||
"""失败的比价(status=failed)不触发发奖。"""
|
||||
a = _login(client, "13800003006")
|
||||
b = _login(client, "13800003007")
|
||||
_bind(client, b, _my_code(client, a))
|
||||
|
||||
_report_compare(client, b, "trace-fail-1", status="failed")
|
||||
assert _invite_cash("13800003006") == 0
|
||||
|
||||
|
||||
def test_compare_reward_isolated_from_coin_cash(client) -> None:
|
||||
"""账户隔离:邀请奖励金进 invite_cash,不串 cash_balance_cents;流水落 invite_cash_transaction。"""
|
||||
a = _login(client, "13800003008")
|
||||
b = _login(client, "13800003009")
|
||||
_bind(client, b, _my_code(client, a))
|
||||
_report_compare(client, b, "trace-iso-1")
|
||||
|
||||
with SessionLocal() as db:
|
||||
ua = get_user_by_phone(db, "13800003008")
|
||||
acc = db.get(CoinAccount, ua.id)
|
||||
assert acc.invite_cash_balance_cents == INVITE_COMPARE_REWARD_CENTS # 奖励金到账
|
||||
assert acc.cash_balance_cents == 0 # 没串进金币现金
|
||||
txns = db.execute(
|
||||
select(InviteCashTransaction).where(
|
||||
InviteCashTransaction.user_id == ua.id,
|
||||
InviteCashTransaction.biz_type == "invite_reward",
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(txns) == 1
|
||||
assert txns[0].amount_cents == INVITE_COMPARE_REWARD_CENTS
|
||||
@@ -46,7 +46,12 @@ def test_account_auto_created_empty(client) -> None:
|
||||
r = client.get("/api/v1/wallet/account", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body == {"coin_balance": 0, "cash_balance_cents": 0, "total_coin_earned": 0}
|
||||
assert body == {
|
||||
"coin_balance": 0,
|
||||
"cash_balance_cents": 0,
|
||||
"invite_cash_balance_cents": 0, # v2 账户隔离新增(邀请奖励金,与现金隔离)
|
||||
"total_coin_earned": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_signin_flow(client) -> None:
|
||||
|
||||
Reference in New Issue
Block a user