feat(invite): 好友列表已比价状态(is_compared) + DEV 造虚拟好友接口; fix(deploy): 每日兑换定时器锁北京时区
邀请: - get_invitees 返回增加 is_compared 字段(= compare_reward_granted), 客户端据此区分好友列表"去提醒/邀请成功"、在途列表只取未比价、算在途好友数与收益。 - 新增 POST/DELETE /invite/seed-fake 开发接口(仅非生产, is_prod 挡):造未比价+已比价虚拟好友, 已比价的走真实入账发 2 元邀请奖励金, 便于真机联调在途/好友列表 UI; 清理原路退回不留脏账。 兑换时区: - daily-exchange.service/.timer 锁定 Asia/Shanghai, 避免按服务器本地时区在错误时刻触发每日兑换。 - 新增 test_daily_exchange_timezone 覆盖同一北京日内幂等。 测试: 邀请 + 兑换时区相关用例全过(28 passed)。 (仓库预存的 3 个 proxy 转发测试失败与本改动无关, 已在干净 HEAD 上复现确认。) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user