diff --git a/app/api/v1/invite.py b/app/api/v1/invite.py index 5bbc99e..56765e6 100644 --- a/app/api/v1/invite.py +++ b/app/api/v1/invite.py @@ -122,6 +122,31 @@ 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( "/landing-track", response_model=LandingTrackOut, diff --git a/app/repositories/invite.py b/app/repositories/invite.py index 8dbd290..cf9e142 100644 --- a/app/repositories/invite.py +++ b/app/repositories/invite.py @@ -304,11 +304,109 @@ 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 + + # ===== 指纹归因(剪贴板兜底,见 [[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/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..60f1718 100644 --- a/tests/test_invite_compare_reward.py +++ b/tests/test_invite_compare_reward.py @@ -74,6 +74,23 @@ def test_compare_reward_idempotent(client) -> None: assert _invite_cash("13800003003") == INVITE_COMPARE_REWARD_CENTS # 仍只发一次 +def test_invitees_is_compared_flag_flips_after_compare(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_compare(client, b, "trace-flag-1") # B 完成一次成功比价 + assert _b_item()["is_compared"] is True # 比价后:已比价(邀请成功) + + def test_compare_no_relation_no_reward(client) -> None: """没有邀请关系的人比价 → 不发奖(没人是他的邀请人)。""" x = _login(client, "13800003005")