13b143cd97
重开分支重提 PR #110 的邀请发奖改动,**不含任何测试文件**(`tests/` 3 个 + `scripts/invite_reward_e2e.*` 2 个 E2E 脚本本地自用,未入库)。 只含 8 个产品/部署文件: - 发奖口径从「上报比价」改为「实际下单」:`order.py` / `compare_record.py` / `repositories/invite.py` - 好友列表返回 `is_compared` 已比价状态:`schemas/invite.py` / `repositories/invite.py` - 新增 DEV 内部接口(生产 `is_prod` 一律 404):`POST/DELETE /invite/seed-fake` 造/清虚拟好友;`POST /invite/dev-reset` 固定测试号打回出厂态 - fix(deploy):每日兑换定时器锁北京时区 `Asia/Shanghai`(`daily-exchange.service` / `.timer`) 对应 PR #110,源分支 `7-1ljh`。 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: no_gen_mu <liujianhishen@gmail.com> Reviewed-on: #113 Co-authored-by: liujiahui <liujiahui@wonderable.ai> Co-committed-by: liujiahui <liujiahui@wonderable.ai>
80 lines
3.7 KiB
Python
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 # 还有下一页吗(完整列表页滚到底加载更多)
|