feat(invite): 发奖口径改为「比价并下单」+ DEV dev-reset 出厂重置接口

- 发奖触发点从「比价成功上报」(compare/record)挪到「实际下单上报」
  (order/report):被邀请人完成比价并真实下单才给邀请人发 2 元邀请奖励金
  (冰 2026-07-02 拍板);仅完成比价不再发奖。幂等闸 compare_reward_granted 不变。
- 新增 POST /api/v1/invite/dev-reset:把固定测试号(TEST_ACCOUNT_PHONE)打回
  出厂态(清邀请关系/奖励金/savings/流水 + 刷 created_at 骗过 72h 新用户闸),
  便于反复当新用户测发奖。双门控:生产 404 + 只认配置的测试号,无鉴权。
- 测试:8 例改为下单驱动 + 新增 2 例覆盖 dev-reset(可反复邀请 / 开关关闭 400)。
This commit is contained in:
no_gen_mu
2026-07-02 22:15:36 +08:00
parent 5cdde8f6c6
commit e2ecd3db9e
7 changed files with 513 additions and 41 deletions
+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,
+26
View File
@@ -147,6 +147,32 @@ def clear_fake_invitees(user: CurrentUser, db: DbSession) -> dict:
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,
+77
View File
@@ -407,6 +407,83 @@ def clear_fake_invitees(db: Session, inviter_id: int) -> int:
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(
+141
View File
@@ -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
}
+160
View File
@@ -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
+86 -26
View File
@@ -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,36 +56,36 @@ 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_invitees_is_compared_flag_flips_after_compare(client) -> None:
"""好友列表 is_compared:好友比价前 False(在途/去提醒)、比价后 True(邀请成功)。"""
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))
@@ -86,35 +96,40 @@ def test_invitees_is_compared_flag_flips_after_compare(client) -> None:
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_compare(client, b, "trace-flag-1") # B 完成一次成功比价
assert _b_item()["is_compared"] is True # 比价后:已比价(邀请成功)
assert _b_item()["is_compared"] is False # 下单前:未完成(在途 / 去提醒)
_report_order(client, b, "evt-flag-1") # B 实际下单一笔
assert _b_item()["is_compared"] is True # 下单后:已完成(邀请成功)
def test_compare_no_relation_no_reward(client) -> None:
"""没有邀请关系的人比价 → 不发奖(没人是他的邀请人)。"""
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")
@@ -129,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