feat(invite): 好友列表已比价状态 + DEV 造虚拟好友接口; fix(deploy): 每日兑换定时器锁北京时区 (#104)

## 改动概述

本 PR 含两个主题(关联 7-1 邀请页 / 兑换时区工作)。

### 1. 邀请好友列表「已比价」状态
- `get_invitees` 返回新增 `is_compared` 字段(= `compare_reward_granted`)。
  客户端据此区分好友列表「去提醒 / 邀请成功」、在途列表只取未比价、算在途好友数与在途收益。
- 新增开发接口 `POST/DELETE /invite/seed-fake`(仅非生产, `is_prod` 挡 404):
  造未比价 + 已比价虚拟好友, 已比价者走真实入账发 2 元邀请奖励金,
  方便真机联调在途 / 好友列表 UI; 清理时原路退回, 不留脏账。

### 2. 每日兑换定时器时区修复
- `deploy/daily-exchange.service` / `.timer` 锁定 `Asia/Shanghai`,
  避免按服务器本地时区在错误时刻触发每日兑换(此前隐患: worker 已用 cn_today,
  但 systemd timer 仍按服务器本地时区)。
- 新增 `test_daily_exchange_timezone` 覆盖同一北京日内幂等。

## 测试
- 邀请 + 兑换时区相关用例全过(`28 passed`)。
- 说明: 仓库预存的 3 个 proxy 转发测试(`test_compare_proxy` / `test_coupon_proxy`)失败,
  已在干净 HEAD 上复现确认与本改动无关, 不在本 PR 处理范围。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: no_gen_mu <liujianhishen@gmail.com>
Reviewed-on: #104
Co-authored-by: liujiahui <liujiahui@wonderable.ai>
Co-committed-by: liujiahui <liujiahui@wonderable.ai>
This commit was merged in pull request #104.
This commit is contained in:
2026-07-03 14:42:50 +08:00
committed by marco
parent ee132aa93b
commit 2229dfaf04
13 changed files with 747 additions and 40 deletions
+82
View File
@@ -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
+6 -2
View File
@@ -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:
+98 -21
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,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