feat(invite): 发奖改「比价并下单」+ 好友 is_compared 状态 + DEV 造数/重置接口 #113

Merged
guke merged 2 commits from 7-1ljh-invite-reward-notest into main 2026-07-03 19:01:38 +08:00
8 changed files with 260 additions and 17 deletions
Showing only changes of commit 4d821bec26 - Show all commits
+4 -15
View File
@@ -21,16 +21,15 @@ 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,
ComparisonRecordCreatedOut,
ComparisonRecordDetailOut,
ComparisonRecordIn,
ComparisonRecordPage,
ComparisonRecordOut,
ComparisonRecordPage,
)
from app.services.pricebot_llm_calls import fetch_llm_calls
logger = logging.getLogger("shagua.compare_record")
@@ -53,18 +52,8 @@ 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)
# 注:邀请发奖已从"比价成功"挪到"实际下单"(见 api/v1/order.py report_order)——
# 冰拍板:被邀请人完成比价并实际下单才算邀请成功,仅完成比价不再发奖
logger.info(
"compare record user=%s trace=%s biz=%s status=%s saved=%s (llm_calls backfill queued)",
user.id,
+51
View File
@@ -122,6 +122,57 @@ def my_invitees(
)
@router.post("/seed-fake", summary="[DEV] 给自己塞虚拟好友(测在途/好友列表 UI,仅非生产)")
def seed_fake_invitees(
user: CurrentUser, db: DbSession, pending: int = 3, compared: int = 2
) -> dict:
"""造 pending 个未比价 + compared 个已比价的虚拟好友。生产环境(is_prod)直接 404。
未比价 → 上"在途列表 / 好友列表去提醒";已比价 → 好友列表"邀请成功"、并发真实邀请奖励金
(可提现余额/累计提现也有数)。清理见 DELETE /seed-fake。
"""
if settings.is_prod:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Not Found")
n = invite_repo.seed_fake_invitees(db, user.id, pending=max(0, pending), compared=max(0, compared))
return {"status": "ok", "created": n, "pending": pending, "compared": compared}
@router.delete("/seed-fake", summary="[DEV] 清掉自己的虚拟好友(仅非生产)")
def clear_fake_invitees(user: CurrentUser, db: DbSession) -> dict:
if settings.is_prod:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Not Found")
n = invite_repo.clear_fake_invitees(db, user.id)
return {"status": "ok", "removed": n}
@router.post("/dev-reset", summary="[DEV] 把固定测试号打回出厂态,便于反复当新用户测发奖(仅非生产)")
def dev_reset(db: DbSession) -> dict:
"""把 TEST_ACCOUNT_PHONE 这个固定测试号清成"从没被邀请/没下过单"的新用户态。
⚠️ 无需鉴权(它只按配置里的测试号操作,不依赖登录态,方便测试脚本第一步直接调)。
双重门控:生产环境(is_prod)404;测试号总开关(TEST_ACCOUNT_PHONE)没配置 → 400。
故它永远只能动那一个配置好的测试号,动不了任意真实用户。
清什么见 repositories/invite.dev_reset_test_account(全清 + 刷 created_at 骗过 72h 闸)。
调完该测试号即可再次绑定 + 下单上报,重新触发一次邀请发奖。
"""
from fastapi import HTTPException
if settings.is_prod:
raise HTTPException(status_code=404, detail="Not Found")
phone = settings.test_account_phone
if not phone:
raise HTTPException(
status_code=400,
detail="测试号未启用:请在 .env 配 TEST_ACCOUNT_PHONE=11111111111 并重启服务",
)
cleared = invite_repo.dev_reset_test_account(db, phone)
logger.info("invite dev-reset phone=%s cleared=%s", phone, cleared)
return {"status": "ok", "phone": phone, "cleared": cleared}
@router.post(
"/landing-track",
response_model=LandingTrackOut,
+19
View File
@@ -1,9 +1,14 @@
import logging
from fastapi import APIRouter
from app.api.deps import CurrentUser, DbSession
from app.repositories import invite as crud_invite
from app.repositories import savings as crud_savings
from app.schemas.order import OrderReportOut, OrderReportRequest
logger = logging.getLogger("shagua.order")
router = APIRouter(prefix="/api/v1/order", tags=["order"])
@@ -15,6 +20,20 @@ router = APIRouter(prefix="/api/v1/order", tags=["order"])
def report_order(req: OrderReportRequest, user: CurrentUser, db: DbSession) -> OrderReportOut:
# 记账唯一真相表是 savings_record(source='compare')。
rec, duplicated = crud_savings.create_from_report(db, user.id, req)
# 邀请 v2 发奖:被邀请人【实际下单】(而非仅完成比价)才给邀请人发邀请奖励金(幂等,只发一次)。
# 触发点从"比价成功上报"挪到这里(冰:完成比价并实际下单才算成功)。仅首次真实上报触发,
# 重复上报(duplicated)跳过。best-effort:发奖异常不影响订单上报本身(rec 已 commit),只 log;
# try_reward_on_compare 自带 compare_reward_granted 幂等闸,漏发靠后续对账补。
if not duplicated:
try:
reward = crud_invite.try_reward_on_compare(db, user.id)
if reward.status == "granted":
logger.info(
"invite order 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 order reward failed invitee=%s: %s", user.id, e)
return OrderReportOut(
id=rec.id,
platform=rec.platform or req.platform,
+175
View File
@@ -304,11 +304,186 @@ def get_invitees(
"avatar_url": u.avatar_url or u.wechat_avatar_url or None,
"coins": rel.inviter_coin, # v3 起恒 0(邀请人收益改走邀请奖励金)
"invited_at": rel.created_at,
# 是否已完成过一次比价(= 已发过邀请奖励金)。客户端据此:好友列表分"去提醒/邀请成功"、
# 在途列表只取未比价(is_compared=False)、算在途好友数与在途收益(未比价数×2元)。
"is_compared": bool(rel.compare_reward_granted),
})
has_more = offset + len(rows) < int(total)
return items, int(total), has_more
# ===== 开发用:塞虚拟好友(仅非生产,方便真机测在途/好友列表 UI)=====
def seed_fake_invitees(db: Session, inviter_id: int, *, pending: int = 3, compared: int = 2) -> int:
"""给 inviter 造 pending 个"未比价"+ compared 个"已比价"的虚拟被邀请好友(仅测试用)。
造真实 User(随机手机号/昵称/头像)+ InviteRelation;已比价的顺带发邀请奖励金(走真实入账,
这样"可提现余额/累计"也有数)。仅非生产环境开放(endpoint 层用 is_prod 挡)。返回新建好友数。
"""
from datetime import datetime, timedelta, timezone
from app.repositories import user as user_repo
# 造点像样的中文昵称 + 打包进 APK 的真人头像(asset://,客户端转 file:///android_asset/ 本地加载,
# 离线必显、不依赖外网,避免 dicebear 这类国外图在国内加载失败成空白)。头像图见客户端
# app/src/main/assets/avatars/fake/p0..p7.jpg,按序号轮询分配。
names = ["小满同学", "一罐可乐Colour", "草原上感受宁静者", "晴天小豆", "momo", "隔壁老王",
"爱吃辣的猫", "月半子", "阿柴", "橘子汽水"]
FAKE_AVATAR_COUNT = 8 # 与客户端 assets/avatars/fake/ 图数一致
created = 0
total = pending + compared
now = datetime.now(timezone.utc)
for i in range(total):
is_compared = i >= pending
# 唯一手机号:99 开头 + 9 位随机(避开真实号段,不与真人撞)
phone = "99" + "".join(secrets.choice("0123456789") for _ in range(9))
nickname = names[i % len(names)] + secrets.choice(["", "", "_", str(secrets.randbelow(99))])
u = User(
phone=phone,
username=user_repo._gen_unique_username(db),
nickname=nickname,
register_channel="fake_seed",
avatar_url=f"asset://avatars/fake/p{i % FAKE_AVATAR_COUNT}.jpg", # 打包头像,轮询分配
status="active",
)
db.add(u)
db.flush() # 拿 u.id
rel = InviteRelation(
inviter_user_id=inviter_id,
invitee_user_id=u.id,
channel="fake_seed",
status="effective",
inviter_coin=0,
invitee_coin=0,
# 邀请时间错开,便于看"时间近→远"排序
created_at=(now - timedelta(hours=i * 6)).replace(tzinfo=None),
)
if is_compared:
# 已比价 → 走真实"好友比价发奖"链路:标记 + 真发 ¥2 进邀请奖励金账户(invite_cash),
# 让"可提现余额/累计/人数/流水/提现记录"全链路真实变化,可验证功能正确。
# (钱只进 invite_cash,不碰金币现金账户;清理 DELETE /seed-fake 会原路退回。)
rel.compare_reward_granted = True
rel.compare_reward_cents = rewards.INVITE_COMPARE_REWARD_CENTS
rel.compare_rewarded_at = now
db.add(rel)
db.flush()
if is_compared:
crud_wallet.grant_invite_cash(
db, inviter_id, rewards.INVITE_COMPARE_REWARD_CENTS,
biz_type="invite_reward", ref_id=str(u.id), remark="虚拟好友比价奖励(测试)",
)
created += 1
db.commit()
return created
def clear_fake_invitees(db: Session, inviter_id: int) -> int:
"""删掉该 inviter 名下所有 channel=fake_seed 的虚拟好友关系 + 对应虚拟 User(测试收尾)。
⚠️已发过邀请奖励金的假关系(compare_reward_granted),删除时必须把当初 grant 的钱**原路退回**
(负数 grant_invite_cash),否则假好友清掉了、发进钱包的奖励金却残留 → invite_cash 余额脏账。
"""
rels = db.execute(
select(InviteRelation).where(
InviteRelation.inviter_user_id == inviter_id,
InviteRelation.channel == "fake_seed",
)
).scalars().all()
n = 0
for rel in rels:
# 先退钱:该关系当初发过奖励金 → 反向扣回同额,钱包不留残
if rel.compare_reward_granted and rel.compare_reward_cents:
crud_wallet.grant_invite_cash(
db, inviter_id, -rel.compare_reward_cents,
biz_type="invite_reward", ref_id=str(rel.invitee_user_id),
remark="虚拟好友清理·奖励金退回(测试)",
)
u = db.get(User, rel.invitee_user_id)
db.delete(rel)
if u is not None and u.register_channel == "fake_seed":
db.delete(u)
n += 1
db.commit()
return n
def dev_reset_test_account(db: Session, phone: str) -> dict:
"""[DEV] 把固定测试号打回"从没被邀请/没下过单"的出厂态,以便反复当新用户测发奖。
清理范围(全清,冰 2026-07-02 拍板):该 user 的
- invite_relation(作为被邀请人的那条 = 解 already_bound;作为邀请人的那些)
- invite_cash_transaction / coin_account 三个余额归零(invite_cash / coin / cash)
- savings_record(= 解 order/report 的 duplicated 幂等)
- invite_fingerprint(作为邀请人落的指纹)
- coin_transaction / cash_transaction / withdraw_order(彻底出厂)
并把 user.created_at 刷成 now(UTC)—— 骗过 bind 的"新用户 72h 窗口"闸(_is_new_user),
否则该号第一次跑之后 created_at 固定、过 72h 即使清了关系也会 not_eligible。
仅供 dev-reset 端点调用(端点已 is_prod→404 + 只认配置的测试号)。返回各表清理条数。
user 不存在(还没登录过)→ 返回全 0,不报错(下一步登录会新建,天然新号)。
"""
from datetime import datetime, timezone
from app.models.invite_fingerprint import InviteFingerprint
from app.models.savings import SavingsRecord
from app.models.wallet import (
CashTransaction,
CoinAccount,
CoinTransaction,
InviteCashTransaction,
WithdrawOrder,
)
user = db.execute(select(User).where(User.phone == phone)).scalar_one_or_none()
if user is None:
return {"user_found": False}
uid = user.id
def _del(model, whereclause) -> int:
rows = db.execute(select(model).where(whereclause)).scalars().all()
for r in rows:
db.delete(r)
return len(rows)
cleared = {
"user_found": True,
"user_id": uid,
"invite_relation_as_invitee": _del(
InviteRelation, InviteRelation.invitee_user_id == uid
),
"invite_relation_as_inviter": _del(
InviteRelation, InviteRelation.inviter_user_id == uid
),
"invite_cash_transaction": _del(
InviteCashTransaction, InviteCashTransaction.user_id == uid
),
"coin_transaction": _del(CoinTransaction, CoinTransaction.user_id == uid),
"cash_transaction": _del(CashTransaction, CashTransaction.user_id == uid),
"withdraw_order": _del(WithdrawOrder, WithdrawOrder.user_id == uid),
"invite_fingerprint": _del(
InviteFingerprint, InviteFingerprint.inviter_user_id == uid
),
"savings_record": _del(SavingsRecord, SavingsRecord.user_id == uid),
}
# 余额账户归零(保留行,只清三个余额;没有账户行则跳过)
acc = db.get(CoinAccount, uid)
if acc is not None:
acc.invite_cash_balance_cents = 0
acc.coin_balance = 0
acc.cash_balance_cents = 0
cleared["coin_account_zeroed"] = True
else:
cleared["coin_account_zeroed"] = False
# 刷 created_at 骗过 72h 新用户闸(存 naive UTC,与登录新建 user 的写法一致)
user.created_at = datetime.now(timezone.utc).replace(tzinfo=None)
cleared["created_at_refreshed"] = True
db.commit()
return cleared
# ===== 指纹归因(剪贴板兜底,见 [[invite-three-tasks]] 任务 3)=====
def record_fingerprint(
+3
View File
@@ -296,6 +296,9 @@ def daily_auto_exchange(db: Session) -> dict:
- **逐用户独立事务**:单个用户异常 rollback 不影响其他人;exchange_coins_to_cash 内部按用户 commit。
返回统计 dict(scanned/converted/skipped_done/skipped_dust/failed/total_cents)。
"""
# ⚠️「今天」写死北京时(rewards.cn_today() = datetime.now(CN_TZ).date(), CN_TZ=+8),与服务器/用户
# 时区无关(用户 2026-07-01 硬约束:兑换一律北京 0 点日切)。**禁止**改成 date.today() / 裸
# datetime.now().date()——那会跟随进程本地时区,服务器非 CST 时会在错误的"天"兑/重复兑。
today = rewards.cn_today()
stats = {
"scanned": 0, "converted": 0, "skipped_done": 0,
+1
View File
@@ -69,6 +69,7 @@ class InviteeItem(BaseModel):
avatar_url: str | None = None # 头像 URL;null = 前端画默认色块
coins: int # 这次邀请给我(邀请人)发的金币
invited_at: datetime # 邀请绑定时间(前端转"今天/3天前")
is_compared: bool = False # 该好友是否已完成过一次比价(好友列表分"去提醒/邀请成功";在途列表只取 False)
class InviteeListOut(BaseModel):
+3
View File
@@ -19,6 +19,9 @@ Type=oneshot
User=root
WorkingDirectory=/opt/shaguabijia-app-server
Environment="PATH=/opt/shaguabijia-app-server/.venv/bin:/usr/bin:/bin"
# 写死北京时:兑换的"当天/0 点"一律按北京时,不随服务器 OS 时区漂(用户 2026-07-01 硬约束)。
# 脚本内的日期判断本就走 rewards.cn_today()(CN_TZ=+8),这里再把进程 TZ 也钉成北京,双保险。
Environment="TZ=Asia/Shanghai"
EnvironmentFile=/opt/shaguabijia-app-server/.env
ExecStart=/opt/shaguabijia-app-server/.venv/bin/python -m scripts.daily_auto_exchange --once
SyslogIdentifier=daily-exchange
+4 -2
View File
@@ -4,8 +4,10 @@
Description=Run daily auto-exchange coins->cash at midnight
[Timer]
# 每天 0 点跑。客户端文案已注明「可能存在延迟」,可按需改 00:05 错开整点扎堆
OnCalendar=*-*-* 00:00:00
# 每天【北京时】0 点跑,写死时区(用户 2026-07-01 硬约束:兑换一律北京 0 点,不随服务器本地时区漂)
# systemd OnCalendar 支持尾缀时区;不写时区会按服务器 OS 本地时区触发 → 服务器非 CST 时会在错误时刻兑。
# 客户端文案已注明「可能存在延迟」,可按需改 00:05 错开整点扎堆。
OnCalendar=*-*-* 00:00:00 Asia/Shanghai
# 服务器宕机/重启后,补跑错过的那一轮(而不是干等次日)。
Persistent=true
AccuracySec=1min