Files
shaguabijia-app-server/app/schemas/invite.py
T
liujiahui 2229dfaf04 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>
2026-07-03 14:42:50 +08:00

80 lines
3.7 KiB
Python

"""邀请相关 API 收发模型。"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class InviteInfoOut(BaseModel):
invite_code: str # 我的邀请码
share_url: str # 落地页链接(含 ?ref=),前端据此生成二维码 + 复制分享
invited_count: int # 已成功邀请人数
coins_earned: int # 累计从邀请获得的金币(v1 口径;v2 邀请人改发奖励金)
reward_balance_cents: int = 0 # v2 可提现邀请奖励金(分)
reward_withdrawn_cents: int = 0 # v2 累计提现成功的邀请奖励金(分)
countdown_days_left: int = 7 # v2 本轮剩余天数(7 天 1 轮)
countdown_is_fresh_round: bool = False # 是否刚进入新一轮(非首轮第1天)
countdown_text: str = "" # 倒计时展示文案(前端直接显示,新轮含换行)
class LandingTrackIn(BaseModel):
"""落地页 dl.html 访问时上报的指纹(用于剪贴板归因失败时兜底)。"""
ref: str = Field(..., min_length=4, max_length=16, description="邀请码(落地页 ?ref=)")
screen: str = Field("", max_length=32, description="屏幕分辨率,如 1080x2400")
# IP / UA 服务端从 HTTP 头自动拿,无需 JS 上报
class LandingTrackOut(BaseModel):
status: str # ok / invalid_code / no_ip
class FingerprintIn(BaseModel):
"""客户端登录后剪贴板归因失败时上报的设备指纹。
跨端匹配字段:浏览器落地页存的同名字段 == 客户端 Build.MODEL / DisplayMetrics 算的值。
IP 服务端从 HTTP 头拿,不在这里。
"""
screen: str = Field("", max_length=32)
device_model: str = Field("", max_length=64)
class BindInviteIn(BaseModel):
# invite_code: 可选 — 走指纹兜底时为空(channel=fingerprint)
invite_code: str | None = Field(
None, max_length=16, description="邀请码;走指纹兜底时为空"
)
channel: str = Field(
"clipboard", max_length=16,
description="归因来源:clipboard(首启读剪贴板自动) / manual(手动填) / fingerprint(指纹兜底)",
)
# 当 invite_code 为空、channel=fingerprint 时,后端用这组指纹反查
fingerprint: FingerprintIn | None = None
class BindInviteOut(BaseModel):
status: str # success / already_bound / invalid_code / self_invite / not_eligible / fp_not_found
coins_awarded: int # 本次给当前用户(被邀请人)发的金币
message: str # 给前端直接展示的文案
class InviteeItem(BaseModel):
"""被邀请人列表的一条(邀请页小窗 / 完整列表页共用)。
display_name / avatar_url 的"降级兜底"在后端算好(见 repositories/invite.get_invitees):
名字 = 昵称 → 微信昵称 → 脱敏手机号(前端拿不到完整号,必须后端脱敏);
头像 = 用户头像 → 微信头像 → null(前端拿到 null 画默认色块)。
"""
display_name: str # 已兜底好的显示名(真名/脱敏号)
avatar_url: str | None = None # 头像 URL;null = 前端画默认色块
coins: int # 这次邀请给我(邀请人)发的金币
invited_at: datetime # 邀请绑定时间(前端转"今天/3天前")
is_compared: bool = False # 该好友是否已完成过一次比价(好友列表分"去提醒/邀请成功";在途列表只取 False)
class InviteeListOut(BaseModel):
"""GET /invite/invitees 响应:被邀请人列表 + 分页。"""
items: list[InviteeItem]
total: int # 我邀请的总人数(用于"共 N 人")
has_more: bool # 还有下一页吗(完整列表页滚到底加载更多)