From 4d821bec26464955237031385b3e926c3e504043 Mon Sep 17 00:00:00 2001 From: no_gen_mu Date: Fri, 3 Jul 2026 17:13:50 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(invite):=20=E5=8F=91=E5=A5=96=E5=8F=A3?= =?UTF-8?q?=E5=BE=84=E6=94=B9=E4=B8=BA=E3=80=8C=E6=AF=94=E4=BB=B7=E5=B9=B6?= =?UTF-8?q?=E4=B8=8B=E5=8D=95=E3=80=8D+=20=E5=A5=BD=E5=8F=8B=E5=B7=B2?= =?UTF-8?q?=E6=AF=94=E4=BB=B7=E7=8A=B6=E6=80=81(is=5Fcompared)=20+=20DEV?= =?UTF-8?q?=20=E9=80=A0=E6=95=B0/=E9=87=8D=E7=BD=AE=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 邀请发奖触发从「上报比价」改为「实际下单」(order.py + compare_record.py + repositories/invite.py) - 好友列表返回 is_compared 已比价状态 (schemas/invite.py + repositories/invite.py) - 新增 DEV 内部接口(生产 is_prod 一律 404): - POST/DELETE /invite/seed-fake 造/清虚拟好友(测在途、好友列表 UI) - POST /invite/dev-reset 把固定测试号打回出厂态(反复当新用户测发奖) - fix(deploy): 每日兑换定时器锁北京时区 Asia/Shanghai (daily-exchange.service/.timer) 不含测试文件(tests/ 及 scripts/invite_reward_e2e.* 本地自用,不入库)。 Co-Authored-By: Claude Opus 4.8 --- app/api/v1/compare_record.py | 19 +--- app/api/v1/invite.py | 51 ++++++++++ app/api/v1/order.py | 19 ++++ app/repositories/invite.py | 175 ++++++++++++++++++++++++++++++++++ app/repositories/wallet.py | 3 + app/schemas/invite.py | 1 + deploy/daily-exchange.service | 3 + deploy/daily-exchange.timer | 6 +- 8 files changed, 260 insertions(+), 17 deletions(-) diff --git a/app/api/v1/compare_record.py b/app/api/v1/compare_record.py index 3e4fc7b..3542030 100644 --- a/app/api/v1/compare_record.py +++ b/app/api/v1/compare_record.py @@ -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, diff --git a/app/api/v1/invite.py b/app/api/v1/invite.py index 5bbc99e..3fe534b 100644 --- a/app/api/v1/invite.py +++ b/app/api/v1/invite.py @@ -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, diff --git a/app/api/v1/order.py b/app/api/v1/order.py index c2f9bb3..86340e3 100644 --- a/app/api/v1/order.py +++ b/app/api/v1/order.py @@ -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, diff --git a/app/repositories/invite.py b/app/repositories/invite.py index 8dbd290..ff7c2da 100644 --- a/app/repositories/invite.py +++ b/app/repositories/invite.py @@ -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( diff --git a/app/repositories/wallet.py b/app/repositories/wallet.py index 1edd034..345cda7 100644 --- a/app/repositories/wallet.py +++ b/app/repositories/wallet.py @@ -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, diff --git a/app/schemas/invite.py b/app/schemas/invite.py index 24e56b4..25da870 100644 --- a/app/schemas/invite.py +++ b/app/schemas/invite.py @@ -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): diff --git a/deploy/daily-exchange.service b/deploy/daily-exchange.service index aef841a..2a6d949 100644 --- a/deploy/daily-exchange.service +++ b/deploy/daily-exchange.service @@ -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 diff --git a/deploy/daily-exchange.timer b/deploy/daily-exchange.timer index d00520b..c6ea4a9 100644 --- a/deploy/daily-exchange.timer +++ b/deploy/daily-exchange.timer @@ -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 -- 2.52.0 From b0e2d7c8d7e9341682c3cfcf61a55009a0c2ac78 Mon Sep 17 00:00:00 2001 From: no_gen_mu Date: Fri, 3 Jul 2026 18:01:28 +0800 Subject: [PATCH 2/2] =?UTF-8?q?refactor(invite):=20=E7=A7=BB=E9=99=A4=20DE?= =?UTF-8?q?V=20=E9=80=A0=E6=95=B0/=E9=87=8D=E7=BD=AE=E6=8E=A5=E5=8F=A3(see?= =?UTF-8?q?d-fake=E3=80=81dev-reset)=E4=B8=8D=E5=85=A5=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评审意见:POST/DELETE /invite/seed-fake、POST /invite/dev-reset 三个接口标注 [DEV]、 且 dev-reset 无鉴权,是本地测试用的造数/重置口子,不应合进 main。 删掉两处: - app/api/v1/invite.py: 三个端点 handler - app/repositories/invite.py: seed_fake_invitees / clear_fake_invitees / dev_reset_test_account 保留 is_compared 好友已比价状态、发奖改「比价并下单」、每日兑换定时器锁北京时区等产品改动。 纯删除,不影响其余接口(/me /invitees /bind /landing-track 均在)。 Co-Authored-By: Claude Opus 4.8 --- app/api/v1/invite.py | 51 ----------- app/repositories/invite.py | 172 ------------------------------------- 2 files changed, 223 deletions(-) diff --git a/app/api/v1/invite.py b/app/api/v1/invite.py index 3fe534b..5bbc99e 100644 --- a/app/api/v1/invite.py +++ b/app/api/v1/invite.py @@ -122,57 +122,6 @@ 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, diff --git a/app/repositories/invite.py b/app/repositories/invite.py index ff7c2da..5af08c5 100644 --- a/app/repositories/invite.py +++ b/app/repositories/invite.py @@ -312,178 +312,6 @@ def get_invitees( 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( -- 2.52.0