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 diff --git a/scripts/invite_reward_e2e.ps1 b/scripts/invite_reward_e2e.ps1 new file mode 100644 index 0000000..3585d0d --- /dev/null +++ b/scripts/invite_reward_e2e.ps1 @@ -0,0 +1,141 @@ +# 邀请发奖真环境验证脚本(方案①:纯后端)—— PowerShell 版 ·【192 局域网 debug 环境】 +# ============================================================================== +# 证明:好友 B 实际下单(POST /api/v1/order/report)→ 邀请人 A 到账 2 元(invite_cash), +# 且「只绑定不下单不发」「重复不重发」「无关系不发」;并证明固定测试号 B 经 +# dev-reset 后可【反复】当新用户,再次触发发奖。 +# 不用真下单 / 不用付钱 / 不用无障碍 / 不用第二台设备。 +# +# 打的是本地局域网 debug 环境(默认 http://192.168.0.144:8770 = 本机 LAN IP)。 +# 两个前提:① SMS_MOCK=true(默认) → 任意 6 位验证码通过;② .env 配了 +# TEST_ACCOUNT_PHONE=11111111111 并【重启过】app-server(B 靠它免验证码 + dev-reset)。 +# +# 用法(PowerShell): +# .\invite_reward_e2e.ps1 # 默认 192.168.0.144:8770 +# .\invite_reward_e2e.ps1 -BaseUrl http://127.0.0.1:8770 +# ============================================================================== +param( + # 目标环境:本地局域网 debug :8770(默认本机 LAN IP) + [string]$BaseUrl = "http://192.168.0.144:8770" +) + +$ErrorActionPreference = "Stop" +$BaseUrl = $BaseUrl.TrimEnd('/') + +# B 固定用测试号(免验证码 + 可 dev-reset 反复当新用户)。必须与 .env 的 TEST_ACCOUNT_PHONE 一致。 +$phoneB = "11111111111" +# A(邀请人)/ X(无关系)用带时间戳的新号。 +$stamp = [int][double]::Parse((Get-Date -UFormat %s)) +$suffix = ($stamp % 100000000) +$phoneX = "139" + ("{0:D8}" -f $suffix) + +$expectCents = 200 # = INVITE_COMPARE_REWARD_CENTS(2 元) + +Write-Host "======================================================================" +Write-Host " 邀请发奖·192 局域网 debug 验证 BASE_URL=$BaseUrl" +Write-Host " B(固定测试号·被邀请人)=$phoneB X(无关系·下单)=$phoneX" +Write-Host "======================================================================" + +# ---- HTTP 小工具(Invoke-RestMethod 自动解析 JSON)--------------------------- +function Invoke-Api { + param([string]$Method, [string]$Path, $Body, [string]$Token) + $headers = @{ "Content-Type" = "application/json; charset=utf-8" } + if ($Token) { $headers["Authorization"] = "Bearer $Token" } + $req = @{ Method = $Method; Uri = "$BaseUrl$Path"; Headers = $headers } + if ($null -ne $Body) { + # 用 UTF8 字节发,保证「美团」等中文不乱码 + $json = ($Body | ConvertTo-Json -Compress) + $req["Body"] = [System.Text.Encoding]::UTF8.GetBytes($json) + } + return Invoke-RestMethod @req +} + +# 登录:发码(mock 不真发)→ 用 123456 登录 → 回 access_token +# 唯一 device_id 避开「device_id+IP 每小时 5 次」限流(测试号 B 本不受限,统一带上无害)。 +function Login { + param([string]$Phone) + $dev = "e2e-$Phone-$stamp" + Invoke-Api POST "/api/v1/auth/sms/send" @{ phone = $Phone; device_id = $dev } | Out-Null + $r = Invoke-Api POST "/api/v1/auth/sms/login" @{ phone = $Phone; code = "123456"; device_id = $dev } + if (-not $r.access_token) { throw "登录失败 phone=$Phone" } + return $r.access_token +} + +# 把固定测试号 B 打回出厂态(清绑定/发奖/savings + 刷 created_at 骗过 72h 闸)。无需鉴权。 +function Reset-TestAccount { + try { + $r = Invoke-Api POST "/api/v1/invite/dev-reset" @{} + } catch { + throw "dev-reset 失败:$($_.Exception.Message) (检查 app-server 是否用最新代码重启?.env 是否配了 TEST_ACCOUNT_PHONE 并重启?)" + } + if ($r.status -ne "ok") { throw "dev-reset 返回非 ok:$($r | ConvertTo-Json -Compress)" } +} + +function Get-Reward { param([string]$Tok) (Invoke-Api GET "/api/v1/invite/me" $null $Tok).reward_balance_cents } +function Get-MyCode { param([string]$Tok) (Invoke-Api GET "/api/v1/invite/me" $null $Tok).invite_code } +function Bind-To { param([string]$Tok,[string]$Code) Invoke-Api POST "/api/v1/invite/bind" @{ invite_code = $Code } $Tok } +function Report-Order { + param([string]$Tok,[string]$EventId) + Invoke-Api POST "/api/v1/order/report" @{ + client_event_id = $EventId + platform = "美团" + pay_channel = "wechat" + compared_price_cents = 2016 + paid_amount_cents = 2016 + } $Tok | Out-Null +} + +# ---- 断言 ------------------------------------------------------------------- +$script:pass = 0; $script:fail = 0 +function Assert-Eq { + param($Expected, $Actual, [string]$Desc) + if ("$Expected" -eq "$Actual") { Write-Host " ✓ $Desc (=$Actual)"; $script:pass++ } + else { Write-Host " ✗ $Desc 期望=$Expected 实得=$Actual" -ForegroundColor Red; $script:fail++ } +} + +# ---- 一轮完整发奖验证:reset B → 新 A 绑定 → B 下单 → A 到账 ----------------- +function Invoke-RewardCycle { + param([int]$Tag) + $phoneA = "138" + ("{0:D8}" -f (($suffix + $Tag) % 100000000)) + Write-Host "`n===== 第 $Tag 轮:A=$phoneA 邀请固定号 B=$phoneB =====" + + Reset-TestAccount # 把 B 打回新用户态 + $tokA = Login $phoneA + $tokB = Login $phoneB + $codeA = Get-MyCode $tokA + Write-Host " A 邀请码=$codeA;B 已 dev-reset 回新用户" + + Assert-Eq 0 (Get-Reward $tokA) "[$Tag] 发奖前 A.invite_cash" + + $bind = Bind-To $tokB $codeA + Write-Host " bind → status=$($bind.status)(期望 success)" + Assert-Eq 0 (Get-Reward $tokA) "[$Tag] 仅绑定后 A.invite_cash(绑定不发)" + + Report-Order $tokB "evt-$stamp-$Tag-1" + Assert-Eq $expectCents (Get-Reward $tokA) "[$Tag] B 首单后 A.invite_cash(下单才发)" + + Report-Order $tokB "evt-$stamp-$Tag-1" # 重复同 event + Report-Order $tokB "evt-$stamp-$Tag-2" # 不同 event 第二笔 + Assert-Eq $expectCents (Get-Reward $tokA) "[$Tag] B 重复/多次上报后 A.invite_cash(只发一次)" +} + +# ============================== 开跑 ========================================= +# 第 1、2 轮:同一个固定号 B 反复当新用户各拿一次发奖(证明 dev-reset 让它可反复用)。 +Invoke-RewardCycle 1 +Invoke-RewardCycle 2 + +# 无关系用户下单 → 不发奖 +Write-Host "`n===== 无关系校验:X 没绑定任何人,下单不应发奖 =====" +$tokX = Login $phoneX +Report-Order $tokX "evt-$stamp-x" +Assert-Eq 0 (Get-Reward $tokX) "无邀请关系 X 下单后 X.invite_cash(不发)" + +# ============================== 结果 ========================================= +Write-Host "`n======================================================================" +if ($script:fail -eq 0) { + Write-Host " ✅ 全部通过($script:pass 项):下单才发奖 / 绑定不发 / 重复不重发 / 无关系不发" -ForegroundColor Green + Write-Host " 且固定号 B 经 dev-reset 后【反复】当新用户两轮均发奖成功。" -ForegroundColor Green + exit 0 +} else { + Write-Host " ❌ 有 $($script:fail) 项未通过(通过 $($script:pass) 项)。看上面 ✗ 行。" -ForegroundColor Red + exit 1 +} diff --git a/scripts/invite_reward_e2e.sh b/scripts/invite_reward_e2e.sh new file mode 100644 index 0000000..a46927d --- /dev/null +++ b/scripts/invite_reward_e2e.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# 邀请发奖真环境验证脚本(方案①:纯后端 curl)——【192 局域网 debug 包环境】 +# ------------------------------------------------------------------------------ +# 证明:好友 B 实际下单(POST /api/v1/order/report)→ 邀请人 A 到账 2 元(invite_cash), +# 且「只绑定不下单不发」「重复上报不重发」「无邀请关系不发」; +# 并证明固定测试号 B 经 dev-reset 后可【反复】当新用户,再次触发发奖。 +# 不用真下单 / 不用付钱 / 不用无障碍 / 不用第二台设备。 +# +# 打的是本地局域网 debug 环境(默认 http://192.168.0.144:8770 = 本机 LAN IP)。 +# 两个前提(不满足脚本白跑): +# ① SMS_MOCK=true(默认即是)→ /sms/login 放行任意 6 位验证码,故用 123456 拿 token。 +# ② .env 里配了 TEST_ACCOUNT_PHONE=11111111111 并【重启过】app-server —— B 用它免验证码 +# 登录,且脚本靠 POST /invite/dev-reset 每轮把它打回新用户态。 +# +# 用法: +# ./invite_reward_e2e.sh # 用默认 192.168.0.144:8770 +# ./invite_reward_e2e.sh http://192.168.0.144:8770 # 位置参数传 base +# BASE_URL=http://127.0.0.1:8770 ./invite_reward_e2e.sh +# 依赖:仅 curl(JSON 用 grep/sed 抽字段,不依赖 python/jq —— Windows Git Bash 里 +# python3 常是 WindowsApps 假 stub,故意不用)。 +# ============================================================================== +set -euo pipefail + +# ---- 目标环境:本地局域网 debug :8770(改成你的开发机内网 IP)------------------ +BASE_URL="${1:-${BASE_URL:-http://192.168.0.144:8770}}" +BASE_URL="${BASE_URL%/}" # 去掉结尾多余的 / + +# B 固定用测试号(免验证码 + 可 dev-reset 反复当新用户)。必须与 .env 的 TEST_ACCOUNT_PHONE 一致。 +PHONE_B="11111111111" + +# A(邀请人)/ X(无关系)每轮用带时间戳的新号:A 需从 0 余额验起、X 只要没关系即可。 +STAMP="$(date +%s)" +SUFFIX="${STAMP: -8}" +PHONE_X="139${SUFFIX}" + +EXPECT_CENTS=200 # = INVITE_COMPARE_REWARD_CENTS(2 元) + +echo "======================================================================" +echo " 邀请发奖·192 局域网 debug 验证 BASE_URL=$BASE_URL" +echo " B(固定测试号·被邀请人)=$PHONE_B X(无关系·下单)=$PHONE_X" +echo "======================================================================" + +# ---- 小工具:从 JSON 取字段(纯 grep/sed,不依赖 python/jq)------------------ +# 用法: echo "$json" | jget access_token +# 支持字符串值("key":"val")与数字值("key":123);只取顶层首个匹配,够本脚本用。 +jget() { + local key="$1" body; body="$(cat)" + local v + v="$(printf '%s' "$body" | grep -o "\"$key\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -1 | sed -E "s/.*:[[:space:]]*\"([^\"]*)\"/\1/")" + if [ -n "$v" ]; then printf '%s' "$v"; return; fi + printf '%s' "$body" | grep -o "\"$key\"[[:space:]]*:[[:space:]]*-\?[0-9]\+" | head -1 | sed -E "s/.*:[[:space:]]*(-?[0-9]+)/\1/" +} + +# ---- POST JSON:body 走 stdin + --data-binary,不内联 -d -------------------- +# ⚠️ Windows Git Bash 里内联 -d "{...美团...}" 会把中文 UTF-8 字节搞乱 → 后端 +# "error parsing the body"。把 body 从 stdin 以原始字节喂进去可避开。 +post_json() { + local path="$1" token="$2" + local auth=() + [ -n "$token" ] && auth=(-H "Authorization: Bearer $token") + curl -sS -X POST "$BASE_URL$path" \ + -H 'Content-Type: application/json' "${auth[@]}" \ + --data-binary @- +} + +# ---- 登录:发码(mock 不真发)→ 用 123456 登录 → 回 access_token ----------- +# 每个号带唯一 device_id(=phone+stamp):登录/发码限流是「device_id+IP 每小时 5 次」, +# 空 device_id 会让本机所有请求挤同一桶,自己把自己限流掉。给唯一 id 各走各的桶。 +# (测试号 B 本就不受该限流约束,但带上 device_id 无害、统一处理。) +login() { + local phone="$1" + local dev="e2e-${phone}-${STAMP}" + post_json /api/v1/auth/sms/send "" <<< "{\"phone\":\"$phone\",\"device_id\":\"$dev\"}" >/dev/null + local resp + resp="$(post_json /api/v1/auth/sms/login "" <<< "{\"phone\":\"$phone\",\"code\":\"123456\",\"device_id\":\"$dev\"}")" + local tok; tok="$(printf '%s' "$resp" | jget access_token)" + if [ -z "$tok" ]; then + echo "✗ 登录失败 phone=$phone,响应:$resp" >&2 + exit 1 + fi + printf '%s' "$tok" +} + +# 把固定测试号 B 打回出厂态(清绑定/发奖/savings + 刷 created_at 骗过 72h 闸)。无需鉴权。 +dev_reset() { + local resp; resp="$(post_json /api/v1/invite/dev-reset "" <<< '{}')" + if [ "$(printf '%s' "$resp" | jget status)" != "ok" ]; then + echo "✗ dev-reset 失败,响应:$resp" >&2 + echo " (检查:app-server 是否已用最新代码重启?.env 是否配了 TEST_ACCOUNT_PHONE 并重启?)" >&2 + exit 1 + fi +} + +# GET /invite/me 的某个字段(邀请码 / 奖励金余额都从这里读) +invite_me() { curl -sS "$BASE_URL/api/v1/invite/me" -H "Authorization: Bearer $1"; } +my_code() { invite_me "$1" | jget invite_code; } +reward() { invite_me "$1" | jget reward_balance_cents; } + +bind_to() { post_json /api/v1/invite/bind "$1" <<< "{\"invite_code\":\"$2\"}"; } +report_order() { + post_json /api/v1/order/report "$1" <<< "{\"client_event_id\":\"$2\",\"platform\":\"美团\",\"pay_channel\":\"wechat\",\"compared_price_cents\":2016,\"paid_amount_cents\":2016}" +} + +# ---- 断言 ------------------------------------------------------------------- +PASS=0; FAIL=0 +assert_eq() { # 期望 实得 说明 + if [ "$1" = "$2" ]; then echo " ✓ $3 (=$2)"; PASS=$((PASS+1)); + else echo " ✗ $3 期望=$1 实得=$2"; FAIL=$((FAIL+1)); fi +} + +# ---- 一轮完整发奖验证:reset B → 新 A 绑定 → B 下单 → A 到账 ----------------- +# $1=轮次标签(用于 event_id 唯一 + 展示)。A 每轮用不同新号(从 0 验起)。 +run_reward_cycle() { + local tag="$1" + local phone_a="138$(printf '%08d' $(( (10#$SUFFIX + tag) % 100000000 )) )" + echo; echo "===== 第 $tag 轮:A=$phone_a 邀请固定号 B=$PHONE_B =====" + + dev_reset # 把 B 打回新用户态 + local tok_a tok_b code_a + tok_a="$(login "$phone_a")" + tok_b="$(login "$PHONE_B")" + code_a="$(my_code "$tok_a")" + echo " A 邀请码=$code_a;B 已 dev-reset 回新用户" + + assert_eq 0 "$(reward "$tok_a")" "[$tag] 发奖前 A.invite_cash" + + local bind_resp; bind_resp="$(bind_to "$tok_b" "$code_a")" + echo " bind → status=$(printf '%s' "$bind_resp" | jget status)(期望 success)" + assert_eq 0 "$(reward "$tok_a")" "[$tag] 仅绑定后 A.invite_cash(绑定不发)" + + report_order "$tok_b" "evt-$STAMP-$tag-1" >/dev/null + assert_eq "$EXPECT_CENTS" "$(reward "$tok_a")" "[$tag] B 首单后 A.invite_cash(下单才发)" + + report_order "$tok_b" "evt-$STAMP-$tag-1" >/dev/null # 重复同 event + report_order "$tok_b" "evt-$STAMP-$tag-2" >/dev/null # 不同 event 的第二笔 + assert_eq "$EXPECT_CENTS" "$(reward "$tok_a")" "[$tag] B 重复/多次上报后 A.invite_cash(只发一次)" +} + +# ============================== 开跑 ========================================= +# 第 1、2 轮:同一个固定号 B 反复当新用户各拿一次发奖(证明 dev-reset 让它可反复用)。 +run_reward_cycle 1 +run_reward_cycle 2 + +# 无关系用户下单 → 不发奖 +echo; echo "===== 无关系校验:X 没绑定任何人,下单不应发奖 =====" +TOK_X="$(login "$PHONE_X")" +report_order "$TOK_X" "evt-$STAMP-x" >/dev/null +assert_eq 0 "$(reward "$TOK_X")" "无邀请关系 X 下单后 X.invite_cash(不发)" + +# ============================== 结果 ========================================= +echo +echo "======================================================================" +if [ "$FAIL" -eq 0 ]; then + echo " ✅ 全部通过($PASS 项):下单才发奖 / 绑定不发 / 重复不重发 / 无关系不发" + echo " 且固定号 B 经 dev-reset 后【反复】当新用户两轮均发奖成功。" + exit 0 +else + echo " ❌ 有 $FAIL 项未通过(通过 $PASS 项)。看上面 ✗ 行。" + exit 1 +fi diff --git a/tests/test_daily_exchange_timezone.py b/tests/test_daily_exchange_timezone.py new file mode 100644 index 0000000..bb684e6 --- /dev/null +++ b/tests/test_daily_exchange_timezone.py @@ -0,0 +1,82 @@ +"""金币→现金「每日自动兑」时区回归测试。 + +用户 2026-07-01 硬约束:不管服务器 / 用户设成什么时区,兑换一律按【北京时 0 点】日切。 +本测试锁死这个行为,防以后有人把 rewards.cn_today() 改回 date.today() / 裸 datetime.now() +(那会跟随进程本地时区,服务器非 CST 时在错误的"天"兑或重复兑)。 +""" +from __future__ import annotations + +from datetime import datetime, timezone + +from app.core import rewards +from app.core.rewards import COIN_PER_CENT +from app.db.session import SessionLocal +from app.repositories import wallet as crud_wallet +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 test_cn_today_is_beijing_date_regardless_of_utc_offset() -> None: + """cn_today() 恒等于北京时的日期,与 UTC / 进程本地时区无关。 + + 取一个 UTC 与北京跨天的时刻(北京已是次日 0 点后、UTC 还在前一天)验证: + 2026-07-01 15:30 UTC = 2026-07-01 23:30 北京(同一天); + 2026-07-01 17:30 UTC = 2026-07-02 01:30 北京(北京已跨天)。 + """ + cst = timezone(rewards.CN_TZ.utcoffset(None)) + # UTC 17:30 → 北京次日 01:30:北京日期应是 07-02 + moment_utc = datetime(2026, 7, 1, 17, 30, tzinfo=timezone.utc) + assert moment_utc.astimezone(cst).date().isoformat() == "2026-07-02" + # 反过来 UTC 23:30 → 北京 07:30 次日:北京日期 07-02(证明 +8 偏移生效) + moment_utc2 = datetime(2026, 7, 1, 23, 30, tzinfo=timezone.utc) + assert moment_utc2.astimezone(cst).date().isoformat() == "2026-07-02" + # cn_today() 的实现就是 datetime.now(CN_TZ).date():与 UTC now 的北京投影一致 + assert rewards.cn_today() == datetime.now(timezone.utc).astimezone(cst).date() + + +def test_daily_auto_exchange_idempotent_within_same_beijing_day(client) -> None: + """同一北京日内多次触发只兑一次(幂等键 = 当天是否已有 exchange_in 北京日流水)。""" + token = _login(client, "13800007701") + assert token + with SessionLocal() as db: + user = get_user_by_phone(db, "13800007701") + assert user is not None + # 给 200 金币(= 2 分),够到分兑换 + crud_wallet.grant_coins(db, user.id, 200, biz_type="test_grant") + db.commit() + + first = crud_wallet.daily_auto_exchange(db) + assert first["converted"] == 1, first + assert first["total_cents"] == 2, first + + # 当天再进账 200 金币,再触发一轮:因当天已有 exchange_in(北京日幂等键)→ 跳过, + # 不重复兑(这 200 留到次日北京 0 点)。若幂等键被改成本地时区,服务器非 CST 时这里会误兑。 + crud_wallet.grant_coins(db, user.id, 200, biz_type="test_grant") + db.commit() + second = crud_wallet.daily_auto_exchange(db) + assert second["converted"] == 0, second + assert second["skipped_done"] >= 1, second + + +def test_has_exchange_in_on_keyed_on_given_beijing_day(client) -> None: + """_has_exchange_in_on 以传入的北京日为界比较 [当天0点, 次日0点)。""" + token = _login(client, "13800007702") + assert token + with SessionLocal() as db: + user = get_user_by_phone(db, "13800007702") + assert user is not None + crud_wallet.grant_coins(db, user.id, 200, biz_type="test_grant") + db.commit() + crud_wallet.daily_auto_exchange(db) + + today = rewards.cn_today() + assert crud_wallet._has_exchange_in_on(db, user.id, today) is True + # 昨天(北京日)不该命中今天兑的流水 + yesterday = today.fromordinal(today.toordinal() - 1) + assert crud_wallet._has_exchange_in_on(db, user.id, yesterday) is False diff --git a/tests/test_invite.py b/tests/test_invite.py index 994ce8b..42c85a7 100644 --- a/tests/test_invite.py +++ b/tests/test_invite.py @@ -73,9 +73,11 @@ def test_bind_flow_no_coins_either_side(client) -> None: assert _coin_balance(client, b) == 0 assert _coin_balance(client, a) == 0 - # A 的战绩:已邀 1 人;金币口径收益恒 0(邀请人收益走邀请奖励金,见 try_reward_on_compare) + # A 的战绩:此时 invited_count=0 —— 口径 = "完成过一次比价的好友数"(compare_reward_granted=True), + # 仅绑定未比价不计入(否则会出现"已邀 1、可提现余额 0",与产品口径"已邀×2=累计提现+余额"对不上; + # 见 get_stats 注释 + 需求 spec"邀请好友人数=邀请的完成一次比价的新用户数量")。金币口径收益恒 0。 r = client.get("/api/v1/invite/me", headers=_auth(a)) - assert r.json()["invited_count"] == 1 + assert r.json()["invited_count"] == 0 assert r.json()["coins_earned"] == 0 @@ -322,6 +324,8 @@ def test_invitees_basic(client) -> None: assert all(it["avatar_url"] is None for it in body["items"]) # v2:绑定时邀请人那份为 0(收益改"好友比价才发奖"),故每条 coins 字段=0 assert all(it["coins"] == 0 for it in body["items"]) + # 刚绑定还没比价 → is_compared 全 False(客户端据此:在途列表取这些、好友列表按钮"去提醒") + assert all(it["is_compared"] is False for it in body["items"]) def test_invitees_order_desc(client) -> None: diff --git a/tests/test_invite_compare_reward.py b/tests/test_invite_compare_reward.py index 3761f3a..089714f 100644 --- a/tests/test_invite_compare_reward.py +++ b/tests/test_invite_compare_reward.py @@ -1,5 +1,8 @@ -"""邀请 v3 比价发奖测试:好友完成成功比价 → 邀请人得邀请奖励金(独立账户),幂等只发一次, +"""邀请 v3 发奖测试:好友完成比价【并实际下单】→ 邀请人得邀请奖励金(独立账户),幂等只发一次, 账户隔离不串金币/现金。被邀请人绑定不得任何奖励由 test_invite.py 覆盖。 + +⚠️ 触发口径(冰拍板):被邀请人仅完成比价【不】发奖,须实际下单(客户端归因后 POST +/api/v1/order/report)才发。故本文件用下单上报驱动发奖,而非比价上报。 """ from __future__ import annotations @@ -30,10 +33,17 @@ 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"): +def _report_order(client, token: str, event_id: str): + """上报一笔归因订单(视为实际下单)→ 触发邀请发奖。event_id 为幂等键。""" return client.post( - "/api/v1/compare/record", - json={"trace_id": trace_id, "status": status}, + "/api/v1/order/report", + json={ + "client_event_id": event_id, + "platform": "美团", + "pay_channel": "wechat", + "compared_price_cents": 2016, + "paid_amount_cents": 2016, + }, headers=_auth(token), ) @@ -46,58 +56,80 @@ def _invite_cash(phone: str) -> int: return acc.invite_cash_balance_cents if acc else 0 -def test_compare_reward_granted_to_inviter(client) -> None: - """B 被 A 邀请后完成一次成功比价 → A 得邀请奖励金 2 元(独立账户),B 不得该奖。""" +def test_order_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") + r = _report_order(client, b, "evt-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 幂等)。""" +def test_order_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 + _report_order(client, b, "evt-idem-1") + _report_order(client, b, "evt-idem-2") # 第二笔下单(不同 event) + _report_order(client, b, "evt-idem-1") # 重复上报同 event assert _invite_cash("13800003003") == INVITE_COMPARE_REWARD_CENTS # 仍只发一次 -def test_compare_no_relation_no_reward(client) -> None: - """没有邀请关系的人比价 → 不发奖(没人是他的邀请人)。""" +def test_invitees_is_compared_flag_flips_after_order(client) -> None: + """好友列表 is_compared:好友下单前 False(在途/去提醒)、下单后 True(邀请成功)。""" + a = _login(client, "13800003021") + b = _login(client, "13800003022") + _bind(client, b, _my_code(client, a)) + with SessionLocal() as db: + b_name = get_user_by_phone(db, "13800003022").nickname + + def _b_item(): + items = client.get("/api/v1/invite/invitees", headers=_auth(a)).json()["items"] + return next(it for it in items if it["display_name"] == b_name) + + assert _b_item()["is_compared"] is False # 下单前:未完成(在途 / 去提醒) + _report_order(client, b, "evt-flag-1") # B 实际下单一笔 + assert _b_item()["is_compared"] is True # 下单后:已完成(邀请成功) + + +def test_order_no_relation_no_reward(client) -> None: + """没有邀请关系的人下单 → 不发奖(没人是他的邀请人)。""" x = _login(client, "13800003005") - r = _report_compare(client, x, "trace-norel-1") + r = _report_order(client, x, "evt-norel-1") assert r.status_code == 200, r.text assert _invite_cash("13800003005") == 0 -def test_compare_failed_no_reward(client) -> None: - """失败的比价(status=failed)不触发发奖。""" +def test_compare_alone_no_reward(client) -> None: + """仅完成比价、未下单 → 不触发发奖(新口径:比价不再发奖)。""" 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 + r = client.post( + "/api/v1/compare/record", + json={"trace_id": "trace-noorder-1", "status": "success"}, + headers=_auth(b), + ) + assert r.status_code == 200, r.text + assert _invite_cash("13800003006") == 0 # 只比价没下单:不发奖 -def test_compare_reward_isolated_from_coin_cash(client) -> None: +def test_order_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") + _report_order(client, b, "evt-iso-1") with SessionLocal() as db: ua = get_user_by_phone(db, "13800003008") @@ -112,3 +144,48 @@ def test_compare_reward_isolated_from_coin_cash(client) -> None: ).scalars().all() assert len(txns) == 1 assert txns[0].amount_cents == INVITE_COMPARE_REWARD_CENTS + + +def test_dev_reset_makes_test_account_fresh_again(client, monkeypatch) -> None: + """dev-reset 让固定测试号 B 可【反复】当新用户:发奖 → reset → 换个邀请人再发奖成功。 + + 验证 POST /api/v1/invite/dev-reset(方案 B)把 B 的 invite_relation / invite_cash / + savings 清掉并刷 created_at,于是它能再次绑定 + 下单触发第二次发奖(第一次的 already_bound / + savings 幂等 / 72h 闸都被 reset 解除)。 + """ + from app.core.config import settings + + test_phone = "11111111111" + # 端点按 settings.test_account_phone 认号 → 用 monkeypatch 在本用例内启用该测试号 + monkeypatch.setattr(settings, "TEST_ACCOUNT_PHONE", test_phone) + + a1 = _login(client, "13800003101") + b = _login(client, test_phone) + + # 第一轮:B 绑 A1 → 下单 → A1 得奖 + assert _bind(client, b, _my_code(client, a1)).json()["status"] == "success" + _report_order(client, b, "evt-reset-r1") + assert _invite_cash("13800003101") == INVITE_COMPARE_REWARD_CENTS + assert _invite_cash(test_phone) == 0 # 被邀请人 B 不得此奖 + + # dev-reset:把 B 打回出厂态(无需鉴权) + r = client.post("/api/v1/invite/dev-reset") + assert r.status_code == 200, r.text + body = r.json() + assert body["status"] == "ok" and body["phone"] == test_phone + assert body["cleared"]["invite_relation_as_invitee"] == 1 # 上一轮那条被邀请关系被清 + + # 第二轮:同一个 B 绑【另一个】邀请人 A2 → 下单 → A2 也得奖(证明 B 又是新用户了) + a2 = _login(client, "13800003102") + assert _bind(client, b, _my_code(client, a2)).json()["status"] == "success" # 不再 already_bound + _report_order(client, b, "evt-reset-r2") + assert _invite_cash("13800003102") == INVITE_COMPARE_REWARD_CENTS # 第二个邀请人到账 + + +def test_dev_reset_blocked_when_test_account_disabled(client, monkeypatch) -> None: + """测试号总开关没配(TEST_ACCOUNT_PHONE 空)→ dev-reset 返回 400(功能未启用)。""" + from app.core.config import settings + + monkeypatch.setattr(settings, "TEST_ACCOUNT_PHONE", "") + r = client.post("/api/v1/invite/dev-reset") + assert r.status_code == 400, r.text