Merge branch 'main' into codex/coupon-point-score

This commit is contained in:
2026-07-22 11:45:51 +08:00
43 changed files with 1518 additions and 440 deletions
+269
View File
@@ -356,3 +356,272 @@ def test_callback_disabled_returns_503(client, monkeypatch) -> None:
# 503 发生在验签/发奖之前,不需要真实用户
r = _callback(client, _signed(1, "trans_disabled"))
assert r.status_code == 503, r.text
# ===== 按 ad_session_id 查权威发奖结果(GET /reward-result/{ad_session_id})=====
# 客户端看完广告轮询它拿弹窗金额:只认 status='granted' 且 coin>0,其余一律不弹。
def _reward_result(client, token: str, session_id: str):
return client.get(f"/api/v1/ad/reward-result/{session_id}", headers=_auth(token))
def _session_extra(session_id: str, **kv: str) -> str:
return json.dumps({"ad_session_id": session_id, **kv})
def test_reward_result_pending_when_s2s_not_arrived(client) -> None:
"""S2S 还没回调 → 200 + pending(**不是 404**),客户端据此继续轮询。"""
token = _login(client, "13800003601")
r = _reward_result(client, token, "sess-not-yet-arrived")
assert r.status_code == 200, r.text
assert r.json() == {
"ad_session_id": "sess-not-yet-arrived",
"status": "pending",
"coin": None,
# 没记录 → 连属于哪一轮都不知道,累计值一并为 null(不是 0,0 会被读成"本轮没赚到")
"round_coin": None,
}
def test_reward_result_returns_granted_coin(client) -> None:
"""S2S 发奖后按会话查 → granted + 本次真实到账额(与钱包入账一致)。"""
phone = "13800003602"
token = _login(client, phone)
uid = _user_id(phone)
session_id = "sess-granted-1"
r = _callback(
client,
_signed(uid, "trans_rr_1", ecpm="200", extra=_session_extra(session_id)),
)
assert r.json() == {"is_verify": True, "reason": 0}
expected = calculate_ad_reward_coin("200", 1)
body = _reward_result(client, token, session_id).json()
assert body["status"] == "granted"
assert body["coin"] == expected
# 弹窗金额必须等于真实入账,这正是本接口存在的意义(不用余额差估算)
assert _coin_balance(client, token) == expected
def test_reward_result_prefers_granted_over_earlier_noshow(client) -> None:
"""竞态:客户端先报 closed_early、S2S 随后才到 → 同一会话两条记录,必须返回 granted 那条。
只按 created_at 取最近一条是不够的(SQLite 下两条可能同一时间戳),故仓储层显式优先 granted。
"""
phone = "13800003603"
token = _login(client, phone)
uid = _user_id(phone)
session_id = "sess-race-noshow"
# 1) 客户端以为没发奖,先留痕
r = client.post(
"/api/v1/ad/reward-noshow",
json={"ad_session_id": session_id, "watched_seconds": 3},
headers=_auth(token),
)
assert r.status_code == 200, r.text
assert r.json()["status"] == "closed_early"
assert _reward_result(client, token, session_id).json()["status"] == "closed_early"
# 2) S2S 姗姗来迟,真发了钱
_callback(client, _signed(uid, "trans_rr_race", ecpm="200", extra=_session_extra(session_id)))
body = _reward_result(client, token, session_id).json()
assert body["status"] == "granted"
assert body["coin"] == calculate_ad_reward_coin("200", 1)
def test_reward_result_capped_reports_zero_not_popup(client) -> None:
"""达每日上限 → capped + coin=0;客户端不弹「获得 0 金币」。"""
phone = "13800003604"
token = _login(client, phone)
uid = _user_id(phone)
session_id = "sess-capped-1"
db = SessionLocal()
try:
db.add(
AdRewardRecord(
trans_id="trans_rr_capped", user_id=_user_id(phone), coin=0, status="capped",
reward_scene="reward_video", ad_session_id=session_id, reward_date="2026-07-17",
)
)
db.commit()
finally:
db.close()
assert uid # 记录挂在该用户名下
body = _reward_result(client, token, session_id).json()
assert body["status"] == "capped"
assert body["coin"] == 0
def test_reward_result_scoped_to_owner(client) -> None:
"""别人的会话查不到(按 user_id 收窄)→ pending,不泄漏他人发奖结果。"""
phone_a = "13800003605"
token_a = _login(client, phone_a)
uid_a = _user_id(phone_a)
token_b = _login(client, "13800003606")
session_id = "sess-owner-only"
_callback(client, _signed(uid_a, "trans_rr_owner", ecpm="200", extra=_session_extra(session_id)))
assert _reward_result(client, token_a, session_id).json()["status"] == "granted"
assert _reward_result(client, token_b, session_id).json()["status"] == "pending"
def test_reward_result_requires_auth(client) -> None:
"""无 Bearer → 401,不裸奔。"""
assert client.get("/api/v1/ad/reward-result/sess-anon-1").status_code == 401
# ===== 膨胀轮累计(boost_round_id → reward-result.round_coin)=====
# 不变量:弹窗数字 == 本轮实际到账之和 == 余额涨幅。三者对不上用户就认为少发了钱。
def _round_extra(session_id: str, round_id: str | None = None, **kv: str) -> str:
data = {"ad_session_id": session_id, **kv}
if round_id is not None:
data["boost_round_id"] = round_id
return json.dumps(data)
def test_round_coin_accumulates_across_ads_in_same_round(client) -> None:
"""一轮连看两条 → round_coin 逐条累计,且等于余额涨幅(第七节验收 1、2 步)。"""
phone = "13800003701"
token = _login(client, phone)
uid = _user_id(phone)
round_id = "b7e1c93a4f6d802b"
_callback(client, _signed(
uid, "trans_round_1", ecpm="200", extra=_round_extra("sess-r1-a", round_id)))
first = calculate_ad_reward_coin("200", 1)
body = _reward_result(client, token, "sess-r1-a").json()
assert body["coin"] == first
assert body["round_coin"] == first # 第 1 条:本轮累计 == 本条
_callback(client, _signed(
uid, "trans_round_2", ecpm="200", extra=_round_extra("sess-r1-b", round_id)))
second = calculate_ad_reward_coin("200", 2) # LT 因子递减,第 2 条比第 1 条少
body = _reward_result(client, token, "sess-r1-b").json()
assert body["coin"] == second
assert body["round_coin"] == first + second # 累计 = 两条之和
# 弹窗数字必须等于真实余额涨幅 —— 这条不变量是整个方案的目的
assert _coin_balance(client, token) == first + second
def test_new_round_restarts_accumulation(client) -> None:
"""换新轮 id → round_coin 从头累计,不接着上一轮往上加(第七节最后一句验收)。"""
phone = "13800003702"
token = _login(client, phone)
uid = _user_id(phone)
_callback(client, _signed(uid, "trans_r2_old", ecpm="200", extra=_round_extra("sess-r2-a", "round-old")))
old = _reward_result(client, token, "sess-r2-a").json()["round_coin"]
assert old > 0
_callback(client, _signed(uid, "trans_r2_new", ecpm="200", extra=_round_extra("sess-r2-b", "round-new")))
body = _reward_result(client, token, "sess-r2-b").json()
assert body["round_coin"] == body["coin"] # 新轮 = 只有本条
assert body["round_coin"] != old + body["coin"]
def test_round_coin_null_without_round_id(client) -> None:
"""extra 没带 boost_round_id(老客户端 / GroMore 丢字段)→ round_coin=null,客户端退回显示单条。"""
phone = "13800003703"
token = _login(client, phone)
uid = _user_id(phone)
_callback(client, _signed(uid, "trans_r3", ecpm="200", extra=_round_extra("sess-r3-noround", None)))
body = _reward_result(client, token, "sess-r3-noround").json()
assert body["coin"] == calculate_ad_reward_coin("200", 1)
assert body["round_coin"] is None
def test_round_coin_null_when_pending(client) -> None:
"""S2S 未到账 → 没有记录 → 连轮 id 都不知道,round_coin 也是 null(不是 0)。"""
token = _login(client, "13800003704")
body = _reward_result(client, token, "sess-r4-pending").json()
assert body == {
"ad_session_id": "sess-r4-pending",
"status": "pending",
"coin": None,
"round_coin": None,
}
def test_round_coin_returned_on_capped(client) -> None:
"""撞每日上限那条不是 granted,但 round_coin **仍返本轮累计**(该条按 0 计)。
客户端的限额 toast 要显示前面几条已到账的总额,不能是空。
"""
phone = "13800003705"
token = _login(client, phone)
uid = _user_id(phone)
round_id = "round-capped"
_callback(client, _signed(uid, "trans_cap_ok", ecpm="200", extra=_round_extra("sess-cap-a", round_id)))
earned = _reward_result(client, token, "sess-cap-a").json()["round_coin"]
assert earned > 0
# 手插一条同轮的 capped 记录(跑满 500 次太慢),模拟第 N 条撞上限
db = SessionLocal()
try:
db.add(AdRewardRecord(
trans_id="trans_cap_hit", user_id=uid, coin=0, status="capped",
reward_scene="reward_video", ad_session_id="sess-cap-b",
reward_date="2026-07-20", boost_round_id=round_id,
))
db.commit()
finally:
db.close()
body = _reward_result(client, token, "sess-cap-b").json()
assert body["status"] == "capped"
assert body["coin"] == 0 # 这条没发钱
assert body["round_coin"] == earned # 但本轮累计照常返回
def test_round_coin_scoped_to_owner(client) -> None:
"""轮 id 是客户端生成的,不能跨用户信任:拿别人的轮 id 查不到别人的金币。"""
phone_a = "13800003706"
token_a = _login(client, phone_a)
uid_a = _user_id(phone_a)
phone_b = "13800003707"
token_b = _login(client, phone_b)
uid_b = _user_id(phone_b)
shared_round = "round-collision"
_callback(client, _signed(uid_a, "trans_own_a", ecpm="200", extra=_round_extra("sess-own-a", shared_round)))
a_total = _reward_result(client, token_a, "sess-own-a").json()["round_coin"]
# B 用同一个轮 id(伪造或碰撞)看一条:B 的累计里不能混进 A 的钱
_callback(client, _signed(uid_b, "trans_own_b", ecpm="200", extra=_round_extra("sess-own-b", shared_round)))
b_body = _reward_result(client, token_b, "sess-own-b").json()
assert b_body["round_coin"] == b_body["coin"]
assert b_body["round_coin"] < a_total + b_body["coin"]
def test_test_grant_accepts_boost_round_id(client, monkeypatch) -> None:
"""debug 的 test-grant 不经 S2S、拿不到 mediaExtra,轮 id 由 body 补 → 本地也能验累计。"""
monkeypatch.setattr(settings, "AD_REWARD_TEST_GRANT_ENABLED", True)
token = _login(client, "13800003708")
round_id = "round-testgrant"
coins = []
for i in range(2):
r = client.post(
"/api/v1/ad/test-grant",
json={"reward_scene": "reward_video", "boost_round_id": round_id,
"ad_session_id": f"sess-tg-{i}-padding"},
headers=_auth(token),
)
assert r.status_code == 200, r.text
coins.append(r.json()["coin"])
body = _reward_result(client, token, "sess-tg-1-padding").json()
assert body["round_coin"] == sum(coins)
+129
View File
@@ -0,0 +1,129 @@
"""广告收益报表的环境与业务代码位口径。"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import delete
from app.admin.repositories import ad_revenue
from app.db.session import SessionLocal
from app.models.ad_ecpm import AdEcpmRecord
from app.models.ad_pangle_revenue import AdPangleDailyRevenue
from app.models.user import User
REPORT_DATE = "2040-02-03"
def test_business_scope_filters_client_and_pangle_by_env_and_code(monkeypatch) -> None:
db = SessionLocal()
try:
user = User(
phone="18800009991",
username="29999999991",
register_channel="sms",
)
db.add(user)
db.flush()
db.add_all([
AdEcpmRecord(
user_id=user.id, ad_type="reward_video", ad_session_id="scope-prod-business",
app_env="prod", our_code_id="prod-reward", ecpm_raw="10000",
report_date=REPORT_DATE, created_at=datetime(2040, 2, 3, tzinfo=UTC),
),
AdEcpmRecord(
user_id=user.id, ad_type="draw", ad_session_id="scope-prod-demo",
app_env="prod", our_code_id="prod-demo", ecpm_raw="20000",
report_date=REPORT_DATE, created_at=datetime(2040, 2, 3, tzinfo=UTC),
),
AdEcpmRecord(
user_id=user.id, ad_type="draw", ad_session_id="scope-prod-known-business",
app_env="prod", our_code_id="104098712", ecpm_raw="40000",
report_date=REPORT_DATE, created_at=datetime(2040, 2, 3, tzinfo=UTC),
),
AdEcpmRecord(
user_id=user.id, ad_type="reward_video", ad_session_id="scope-test-business",
app_env="test", our_code_id="104127529", ecpm_raw="30000",
report_date=REPORT_DATE, created_at=datetime(2040, 2, 3, tzinfo=UTC),
),
AdPangleDailyRevenue(
report_date=REPORT_DATE, app_env="prod", our_code_id="prod-reward",
adn="", revenue_yuan=1.5, api_revenue_yuan=1.2, impressions=10,
),
AdPangleDailyRevenue(
report_date=REPORT_DATE, app_env="prod", our_code_id="prod-demo",
adn="", revenue_yuan=8.0, api_revenue_yuan=7.0, impressions=40,
),
AdPangleDailyRevenue(
report_date=REPORT_DATE, app_env="prod", our_code_id="104098712",
adn="", revenue_yuan=2.5, api_revenue_yuan=2.0, impressions=20,
),
AdPangleDailyRevenue(
report_date=REPORT_DATE, app_env="test", our_code_id="104127529",
adn="", revenue_yuan=9.0, api_revenue_yuan=8.0, impressions=50,
),
])
db.commit()
monkeypatch.setattr(
ad_revenue.app_config,
"get_ad_config",
lambda _db: {
"reward_code_id": "prod-reward",
"compare_draw_code_id": "prod-draw",
"coupon_draw_code_id": "prod-draw",
},
)
business = ad_revenue.ad_revenue_report(
db,
date_from=REPORT_DATE,
date_to=REPORT_DATE,
app_env="prod",
revenue_scope="business",
)
assert business["total_impressions"] == 2
assert business["total_revenue_yuan"] == 0.5
assert business["total_pangle_revenue_yuan"] == 4.0
assert business["total_pangle_api_revenue_yuan"] == 3.2
all_codes = ad_revenue.ad_revenue_report(
db,
date_from=REPORT_DATE,
date_to=REPORT_DATE,
app_env="prod",
revenue_scope="all",
)
assert all_codes["total_impressions"] == 3
assert all_codes["total_revenue_yuan"] == 0.7
assert all_codes["total_pangle_revenue_yuan"] == 12.0
assert all_codes["total_pangle_api_revenue_yuan"] == 10.2
test_business = ad_revenue.ad_revenue_report(
db,
date_from=REPORT_DATE,
date_to=REPORT_DATE,
app_env="test",
revenue_scope="business",
)
assert test_business["total_impressions"] == 1
assert test_business["total_revenue_yuan"] == 0.3
assert test_business["total_pangle_revenue_yuan"] == 9.0
assert test_business["total_pangle_api_revenue_yuan"] == 8.0
all_env_business = ad_revenue.ad_revenue_report(
db,
date_from=REPORT_DATE,
date_to=REPORT_DATE,
app_env=None,
revenue_scope="business",
)
assert all_env_business["total_impressions"] == 3
assert all_env_business["total_revenue_yuan"] == 0.8
assert all_env_business["total_pangle_revenue_yuan"] == 13.0
assert all_env_business["total_pangle_api_revenue_yuan"] == 11.2
finally:
db.rollback()
db.execute(delete(AdPangleDailyRevenue).where(AdPangleDailyRevenue.report_date == REPORT_DATE))
db.execute(delete(AdEcpmRecord).where(AdEcpmRecord.report_date == REPORT_DATE))
db.execute(delete(User).where(User.phone == "18800009991"))
db.commit()
db.close()
+65
View File
@@ -0,0 +1,65 @@
"""比价记录页后端分页与概览聚合。"""
from __future__ import annotations
from datetime import UTC, date, datetime
import pytest
from app.admin.repositories import queries
from app.db.session import SessionLocal
from app.models.comparison import ComparisonRecord
def test_summary_uses_only_success_durations_and_filters_beijing_date() -> None:
db = SessionLocal()
try:
rows = [
("summary-success-a", "success", 1000, 1.0, 100),
("summary-success-b", "success", 3000, 2.0, 0),
("summary-failed", "failed", 100_000, 3.0, 0),
("summary-cancelled", "cancelled", 5000, 4.0, 0),
]
for trace_id, status, total_ms, cost, saved in rows:
db.add(ComparisonRecord(
trace_id=trace_id,
status=status,
total_ms=total_ms,
llm_cost_yuan=cost,
saved_amount_cents=saved,
created_at=datetime(2038, 1, 15, 12, tzinfo=UTC),
))
db.add(ComparisonRecord(
trace_id="summary-outside-day",
status="success",
total_ms=999_999,
created_at=datetime(2038, 1, 16, 16, tzinfo=UTC),
))
db.flush()
summary = queries.comparison_records_summary(
db, date_from=date(2038, 1, 15), date_to=date(2038, 1, 15)
)
assert summary["started"] == 4
assert summary["completed"] == 3
assert summary["success"] == 2
assert summary["success_rate"] == pytest.approx(2 / 3)
assert summary["avg_token_cost"] == pytest.approx(2.5)
assert summary["lower_price_rate"] == 0.5
assert summary["avg_duration_ms"] == 2000
assert summary["p5_duration_ms"] == 1100
assert summary["p50_duration_ms"] == 2000
assert summary["p95_duration_ms"] == 2900
assert summary["p99_duration_ms"] == 2980
assert summary["cancelled"] == 1
assert summary["cancelled_rate"] == 0.25
assert summary["cancelled_p50_ms"] == 5000
items, _next_cursor, total = queries.list_comparison_records(
db, date_from=date(2038, 1, 15), date_to=date(2038, 1, 15), limit=20
)
assert total == 4
assert {item.trace_id for item in items} == {row[0] for row in rows}
finally:
db.rollback()
db.close()
-43
View File
@@ -11,7 +11,6 @@ from app.core.rewards import (
COIN_PER_CENT,
COIN_PER_YUAN,
MIN_EXCHANGE_COIN,
SIGNIN_BOOST_COIN,
SIGNIN_REWARDS,
TASK_ENABLE_NOTIFICATION,
TASK_REWARDS,
@@ -106,48 +105,6 @@ def test_signin_flow(client) -> None:
assert txn["balance_after"] == SIGNIN_REWARDS[0]
def test_signin_boost_flow(client) -> None:
"""签到后看广告膨胀 → S2S 固定补发 2000 金币,每天只能膨胀一次。"""
phone = "13800001011"
token = _login(client, phone)
r = client.post("/api/v1/signin/boost", json={}, headers=_auth(token))
assert r.status_code == 409
r = client.post("/api/v1/signin", headers=_auth(token))
assert r.status_code == 200, r.text
first_coin = r.json()["coin_awarded"]
with SessionLocal() as db:
user = get_user_by_phone(db, phone)
assert user is not None
uid = user.id
extra = json.dumps({"reward_scene": "signin_boost", "ad_session_id": "signin-session-1"})
r = client.get(
"/api/v1/ad/pangle-callback",
params=_signed_ad_callback(uid, "signin-boost-trans-1", extra=extra, ecpm="200"),
)
assert r.status_code == 200, r.text
assert r.json() == {"is_verify": True, "reason": 0}
r = client.post("/api/v1/signin/boost", json={"ad_ref_id": "signin-boost-trans-1"}, headers=_auth(token))
assert r.status_code == 200, r.text
body = r.json()
assert body["coin_awarded"] == SIGNIN_BOOST_COIN
assert body["coin_balance"] == first_coin + SIGNIN_BOOST_COIN
r = client.get(
"/api/v1/ad/pangle-callback",
params=_signed_ad_callback(uid, "signin-boost-trans-2", extra=extra, ecpm="200"),
)
assert r.status_code == 200, r.text
r = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token))
types = [t["biz_type"] for t in r.json()["items"]]
assert "signin" in types
assert "signin_boost" in types
def test_task_claim_flow(client) -> None:
"""打开消息提醒=可重复任务:每次领取金额减半(750/375/188),claimed 恒 False,余额累加。"""